3. Windowing & timing

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.

The text cursor

mMsg_Main_Cursol_ControlCursol (src/game/m_msg_cursol.c_inc:1100) runs once per game frame while a message is scrolling. Its loop:

  1. Decrement 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.)
  2. Skip newlines, message tags and control codes, executing their side effects.
  3. On a printable character: advance 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 — 30 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.

Building the (a, b, c) window

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.

Firing

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.

Scale → volume

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.

End of message

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.

The resulting cadence

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 average about 190 ms, so syllables overlap heavily — which is why there are two alternating subtracks.

← 2. Text → voice code 4. The phonology engine →