The phonology engine needs three characters at once. Getting them there takes two cursor steps, a pair of status flags, and a trigger that fires from the font renderer rather than the message logic.
mMsg_Main_Cursol_ControlCursol (src/game/m_msg_cursol.c_inc:1100)
runs once per game frame while a message is scrolling. Its loop:
cursor_timer; if still positive, do nothing this frame.
(Control code mFont_CONT_CODE_CURSOR_SET_TIME, value 3, sets it — this is
how messages pause mid-sentence.)end_text_cursor_idx
only if mMsg_STATUS_FLAG_NOT_PAUSE_FRAME is clear,
then toggle that flag, then call mMsg_sound_CodeVoice(msg_p, cur_idx)
and break.The flag toggle is the speed control: the index moves on alternate frames, so text advances at one character per two frames, roughly 33 characters/second at 60 fps.
Holding B sets mMsg_STATUS_FLAG_FAST_TEXT, which zeroes
cursor_timer and clears NOT_PAUSE_FRAME each frame, giving one
character per frame. It also switches the reported message speed from 2 to 1
(mMsg_sound_MessageSpeedClear), which the audio engine stores in
sou_message_speed.
mMsg_sound_CodeVoice (m_msg_sound.c_inc:126) converts the character
and hands it to mMsg_sound_voice_entry (:85), which is the state
machine:
if (idx == msg_p->voice_sfx_idx) return; // already seen this index
if (idx == msg_p->end_text_cursor_idx) return; // this is a non-advancing frame
msg_p->voice_sfx_idx = idx;
if (status_flags & VOICE_ENTRY) {
// second character of the pair
voice2_idx = voice_no; // b
voice3_idx = lookahead(idx + code_size); // c, or 0xFF at a newline
status_flags |= FLAG_18;
}
else if (voice_no != 0x81) { // a space cannot open a pair
voice_idx = voice_no; // a
voice2_idx = voice3_idx = 0xFF;
animal_voice_idx = npc_voice_id;
status_flags |= VOICE_ENTRY;
}
Two guards deserve attention. The end_text_cursor_idx comparison is what
makes the every-other-frame toggle actually halve the entry rate — on a
non-advancing frame the pre-advance index equals the post-advance index and the call
is dropped. And a space (0x81) is never allowed to be the a of a
window, only the b; this is why utterances tend to break after a
space rather than starting on one.
The c lookahead reads one character past b and clamps a newline or a
control code to 0xFF. It exists for exactly two rules in the phonology:
you and magic e.
Nothing plays from the message code. The trigger is in the draw pass —
mMsg_draw_font (src/game/m_msg_draw_font.c_inc:35) decides whether
this frame's glyph should speak:
if (status_flags & VOICE_ENTRY) {
if (status_flags & FLAG_18) voice = TRUE; // pair complete
else if (cursor_timer != 0 || force_voice_enable_flag) voice = TRUE;
}
It then finds the line containing voice_sfx_idx, sets
mFont_SENTENCE_FLAG_VOICE_SE on that line's mFontSentence, and clears
both flags. mFontSentence_gppDraw_main
(src/game/m_font_main.c_inc:1078) calls the speak function immediately after
drawing the glyph at index se_pos, so audio is frame-locked to the character
appearing on screen.
The second branch is the escape hatch: if the cursor is paused (a
CURSOR_SET_TIME code) or a control code returned
mMsg_RESULT_TRUE, a half-built window with b = c = 0xFF fires anyway
rather than being held indefinitely.
mFontSentence_animal_voice_se (m_font_main.c_inc:951) computes the
scale argument from the glyph's own transform:
f_scale = sqrtf(scale.x² + scale.y²); scale = line_scale * f_scale * (32.0f / √2) + 0.5f; // clamped 0…255
32 is normal size, so a normally scaled glyph passes scale == 32 and the
volume adjustment is skipped entirely. (What happens for other values is
not what it looks like.)
It also contains one very specific special case: if the character is ♪ at exactly
normal scale in colour rgba(50, 60, 50, 255) and the NPC id is non-zero, that id
is forced to 0. This does not silence it — Na_VoiceSe's ♪ escape
only bails on voice == 0xFA, and melody_seq_offset[0] /
melody_seq_size[0] are a real 0x120-byte entry. The effect is to play
the figure on instrument 0 rather than the speaker's own, presumably so one particular
line reads as a generic note instead of that character's timbre.
mMsg_sound_voice_endcode_set (m_msg_sound.c_inc:117) sends
sAdo_VoiceSe(0x84, 0xFF, 0xFF, npc, 32, 0) — a synthetic newline — and clears
both window flags. It runs when the message ends, and also when the
mFont_CONT_CODE_SOUND_CUT control code (value 81) appears, which additionally
plays system SE 0x54 and suppresses the rest of the line's voice.
frame 0 char A entry: a = A (nothing plays)
frame 1 — non-advancing frame, dropped
frame 2 char B entry: b = B, c = lookahead(C)
draw: sAdo_VoiceSe(A, B, C, …) ← one call
frame 3 — Sou_SpecialRoutine10 replays B as mode 2
frame 4 char C entry: a = C
…
So: one Na_VoiceSe call per two characters, one pair every four frames
(~66 ms), with the deferred second syllable one game frame (~17 ms) behind the first.
Samples run about 190 ms, so syllables overlap heavily — which is why there are
two alternating subtracks.