aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/editor.html116
-rw-r--r--docs/encoding.html136
-rw-r--r--docs/gating.html180
-rw-r--r--docs/index.html101
-rw-r--r--docs/phonology.html235
-rw-r--r--docs/playback.html196
-rw-r--r--docs/quirks.html157
-rw-r--r--docs/reference.html217
-rw-r--r--docs/scheduling.html147
-rw-r--r--docs/soundfont.html226
-rw-r--r--docs/style.css83
11 files changed, 1794 insertions, 0 deletions
diff --git a/docs/editor.html b/docs/editor.html
new file mode 100644
index 0000000..8e46242
--- /dev/null
+++ b/docs/editor.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>7. The editor path — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 7. The editor path</nav>
+
+<h1>7. The editor path</h1>
+<p class="lede">There is a third calling convention into <code>Na_VoiceSe</code> that skips
+the phonology engine entirely, and it explains what a third of the voice samples are
+for.</p>
+
+<h2>Mode 1</h2>
+
+<p>The text editor overlay — used for letters, town and player names, patterns, and
+anywhere else the player types — speaks each keystroke from
+<code>src/game/m_editor_ovl.c:712</code>:</p>
+
+<pre>if (command == mED_COMMAND_OUTPUT_CODE) {
+ int voice = mMsg_sound_voice_get_for_editor(editor_ovl-&gt;now_code);
+
+ if (voice == 0x85 || voice == 0x80 || voice == 0x81 ||
+ voice == 0x82 || voice == 0x83 || voice == -1)
+ voice = 0x86;
+
+ sAdo_VoiceSe(voice, 0xFF, 0xFF, 0, 32, 1); // ← mode 1
+}
+else if (command == mED_COMMAND_EXCHANGE_CODE) { // cursor moved onto a character
+ if (exchange_code != -1 &amp;&amp; mED_exchange_se_list[exchange_code])
+ sAdo_VoiceSe(mMsg_sound_voice_get_for_editor(exchange_code), 0xFF, 0xFF, 0, 32, 1);
+ else
+ sAdo_SysTrgStart(0x36);
+}</pre>
+
+<p>After both filters, only three things survive: letters (<code>0x5D</code>–<code>0x76</code>),
+digits (<code>0x53</code>–<code>0x5C</code>), and the click (<code>0x86</code>).</p>
+
+<h2>What mode 1 does inside Na_VoiceSe</h2>
+
+<p><code>Na_VoiceSe</code>'s two <code>switch (f)</code> blocks handle only cases 0 and 2.
+With <code>f == 1</code> both fall through, so:</p>
+
+<ul>
+ <li>no digraph merge, no vowel-length table, no silent&nbsp;e;</li>
+ <li><code>sou_num_org</code> etc. are never written, so <code>sou_last_num =
+ sou_num_org</code> further down just rewrites stale values — the message-path
+ history is not corrupted, but it is not updated either;</li>
+ <li><code>r28</code> is left uninitialised and never read;</li>
+ <li>nothing is queued for <code>Sou_SpecialRoutine10</code>;</li>
+ <li><strong><code>a</code> arrives at the playback stage as the raw voice code.</strong></li>
+</ul>
+
+<p>The editor always runs at spec 5 or 6 (see <a href="gating.html">gating</a>), which
+routes <code>0x86</code> to <code>NA_SE_BEBE</code> and passes everything else through. The
+final guard is <code>if (a != 0 &amp;&amp; a &lt;= 0x77)</code>, and letters and digits both
+satisfy it. So:</p>
+
+<pre>typing 'a' → Sou_VoiceStart(subtrack, 0x5D) → instrument 0x5C
+typing 'z' → Sou_VoiceStart(subtrack, 0x76) → instrument 0x75
+typing '0' → Sou_VoiceStart(subtrack, 0x53) → instrument 0x52</pre>
+
+<h2>What this tells us about the sample set</h2>
+
+<p>Trace the reachable phoneme codes across the whole engine:</p>
+
+<table>
+<tr><th>Code range</th><th>Instruments</th><th>Reached by</th></tr>
+<tr><td><code>0x01</code>–<code>0x52</code></td><td>0–81</td><td>the phonology engine — short/long vowels, consonants, all 52 two-character digraphs plus <code>you</code></td></tr>
+<tr><td><code>0x01</code>–<code>0x40</code></td><td></td><td>also the anti-repetition random substitution (<code>rand() &gt;&gt; 2</code>)</td></tr>
+<tr><td><code>0x53</code>–<code>0x76</code></td><td>82–117</td><td><strong>only mode 1</strong> — one entry per digit and letter, in order</td></tr>
+<tr><td><code>0x77</code></td><td>118</td><td>the <code>you</code> digraph, and nothing else</td></tr>
+</table>
+
+<p>The <code>0x53</code>–<code>0x76</code> block is exactly 36 codes: ten digits then
+twenty-six letters, in the same order as the voice-code alphabet, and it is
+unreachable through any dialogue. The obvious reading is that these 36 samples are the
+<strong>spoken names of the digits and letters</strong> — what you hear when typing a name
+one character at a time.</p>
+
+<div class="note">
+<strong>Confidence.</strong> The code path is unambiguous; the interpretation of the samples
+as letter names is inference. It is testable: extract instruments 82–117 from bank 156
+and listen. It also predicts that bank 157, which stops at 83 instruments, is a
+phonology-only bank — and that is exactly what it is.
+</div>
+
+<h2>Why bank 157 is short</h2>
+
+<p>Spec 3 (Wendell, Pete, Booker, Jack) uses sequence 244 / bank 157, which has 83
+instruments — codes <code>0x01</code>–<code>0x53</code>. That covers the entire phonology
+output range (<code>≤ 0x52</code>) with one to spare, and omits the letter-name block
+that spec 3 can never reach, since the editor is always spec 5 or 6.</p>
+
+<p>The one real casualty is <code>you</code> (<code>0x77</code>): the sequence's dynamic
+table clamps out-of-range entries to the last routine, so a spec-3 voice saying "you"
+plays instrument 82 instead. That is one wrong syllable on a common word, in four
+NPCs' voices.</p>
+
+<div class="note">
+This corrects an earlier reading of <code>extraction.md</code> §2.4, which described bank 157
+as "collapsing every digraph sample above <code>0x53</code> onto one sound". Nothing above
+<code>0x53</code> is a digraph — the block is not phonology at all.
+</div>
+
+<div class="pager">
+ <span><a href="soundfont.html">← 6. The soundfont lookup</a></span>
+ <span><a href="quirks.html">8. Quirks &amp; corrections →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/encoding.html b/docs/encoding.html
new file mode 100644
index 0000000..8b57261
--- /dev/null
+++ b/docs/encoding.html
@@ -0,0 +1,136 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>2. Text → voice code — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 2. Text → voice code</nav>
+
+<h1>2. Text → voice code</h1>
+<p class="lede">A flat 256-entry table turns the game's own character encoding into a
+34-value "voice code" alphabet. This is the only place text is looked at.</p>
+
+<h2>The game's character set</h2>
+
+<p>Messages are stored one byte per character in a custom encoding
+(<code>include/m_font.h</code>). It is ASCII-compatible over exactly three ranges —
+<code>0-9</code> at 48–57, <code>A-Z</code> at 65–90, <code>a-z</code> at 97–122 — and
+diverges everywhere else. Notable non-ASCII placements:</p>
+
+<table>
+<tr><th>Code</th><th>Character</th><th>Note</th></tr>
+<tr><td>0–31</td><td>uppercase accented Latin, <code>¡ ¿ Ç Ð Ñ ß Þ</code></td><td>where ASCII control codes would be</td></tr>
+<tr><td>43</td><td><code>♥</code></td><td>where ASCII has <code>+</code></td></tr>
+<tr><td>47</td><td><code>♪</code></td><td>where ASCII has <code>/</code></td></tr>
+<tr><td>59</td><td>droplet symbol</td><td>where ASCII has <code>;</code></td></tr>
+<tr><td>92</td><td>annoyed symbol</td><td>where ASCII has <code>\</code></td></tr>
+<tr><td>127</td><td><code>CHAR_CONTROL_CODE</code></td><td>escape byte; a control code follows</td></tr>
+<tr><td>128</td><td><code>CHAR_MESSAGE_TAG</code></td><td>escape byte</td></tr>
+<tr><td>210, 211</td><td>short space, wide space</td><td>layout-only spaces</td></tr>
+</table>
+
+<h2>The voice table</h2>
+
+<p><code>mMsg_sound_voice_get(code)</code> (<code>src/game/m_msg_sound.c_inc:1</code>) is a
+256-byte lookup with two hard-coded exceptions:</p>
+
+<pre>if (code == CHAR_CONTROL_CODE || code == CHAR_MESSAGE_TAG)
+ return -1; // no sound, and no state change
+return voice_array[code];</pre>
+
+<p>Those are the only two entries in the array holding <code>0xFF</code>, so
+<code>0xFF</code> is never actually returned from this function — it arrives at
+<code>Na_VoiceSe</code> only as a padding value for the unused <code>b</code>/<code>c</code>
+slots.</p>
+
+<h3>Voice code space</h3>
+
+<table>
+<tr><th>Voice code</th><th>Meaning</th></tr>
+<tr><td><code>0x53</code>–<code>0x5C</code></td><td>digits <code>0</code>–<code>9</code></td></tr>
+<tr><td><code>0x5D</code>–<code>0x76</code></td><td>letters <code>a</code>–<code>z</code></td></tr>
+<tr><td><code>0x80</code></td><td><code>♪</code> — plays the speaker's melody, bypasses everything</td></tr>
+<tr><td><code>0x81</code></td><td>space</td></tr>
+<tr><td><code>0x82</code></td><td><code>?</code> — raises pitch</td></tr>
+<tr><td><code>0x83</code></td><td><code>!</code> — raises volume</td></tr>
+<tr><td><code>0x84</code></td><td>newline (injected, never in the table)</td></tr>
+<tr><td><code>0x85</code></td><td>mute — the catch-all for punctuation and symbols</td></tr>
+<tr><td><code>0x86</code></td><td>click — plays <code>NA_SE_BEBE</code></td></tr>
+<tr><td><code>0xFF</code></td><td>empty slot in a window</td></tr>
+</table>
+
+<p>The split at <code>0x77</code> matters: <code>Sou_BoinShiinCheck</code> and several rules in
+<code>Na_VoiceSe</code> test <code>&gt; 0x77</code> to mean "this is punctuation, not a letter".</p>
+
+<h3>Case and accent folding</h3>
+
+<p>Upper and lower case map to the same code, and every accented Latin letter folds onto
+its base letter. Some of the folds are worth knowing about:</p>
+
+<table>
+<tr><th>Character(s)</th><th>Folds to</th></tr>
+<tr><td><code>À Á Â Ã Å à á â ã ä å</code>, <code>ª</code> (feminine ordinal)</td><td><code>a</code></td></tr>
+<tr><td><code>ß</code> (eszett)</td><td><code>b</code> — a glyph-shape fold, not a phonetic one</td></tr>
+<tr><td><code>Ç ç</code></td><td><code>c</code></td></tr>
+<tr><td><code>Ð ð</code></td><td><code>d</code></td></tr>
+<tr><td><code>È É Ê Ë è é ê ë</code></td><td><code>e</code></td></tr>
+<tr><td><code>Ì Í Î Ï ì í î ï</code></td><td><code>i</code></td></tr>
+<tr><td><code>Ñ ñ</code></td><td><code>n</code></td></tr>
+<tr><td><code>Ò Ó Ô Õ Ö ò ó ô õ ö</code>, <code>º</code> (masculine ordinal)</td><td><code>o</code></td></tr>
+<tr><td><code>Þ þ</code> (thorn)</td><td><code>p</code></td></tr>
+<tr><td><code>Ù Ú Û Ü ù ú û ü</code></td><td><code>u</code></td></tr>
+<tr><td><code>Ý ý ÿ</code></td><td><code>y</code></td></tr>
+<tr><td><code>Œ œ</code></td><td><em>mute</em> — the only accented Latin pair that is not folded</td></tr>
+</table>
+
+<h3>Everything else</h3>
+
+<ul>
+ <li>Space (32), short space (210), wide space (211), and code 255 → <code>0x81</code>.</li>
+ <li>Interpunct (133) → <code>0x86</code>, the click. It is the only character in the whole
+ table that maps to the click code.</li>
+ <li>All other punctuation, symbols, and the entire block from 155 upward →
+ <code>0x85</code>, mute.</li>
+</ul>
+
+<h2>Two wrappers</h2>
+
+<h3>mMsg_sound_voice_get2</h3>
+
+<pre>if (code == CHAR_NEW_LINE) return 0x84;
+return mMsg_sound_voice_get(code);</pre>
+
+<p>Newline has no entry in the table; the wrapper injects <code>0x84</code>. This is the
+function the scheduler actually calls, so <code>0x84</code> reaches the engine while the
+raw table never produces it. <span class="src"><code>m_msg_sound.c_inc:44</code></span></p>
+
+<h3>mMsg_sound_voice_get_for_editor</h3>
+
+<pre>sound = mMsg_sound_voice_get(code);
+switch (sound) {
+ case 0x80: case 0x82: case 0x83: case 0x85: return 0x86;
+ default: return sound;
+}</pre>
+
+<p>In the text editor, ♪, <code>?</code>, <code>!</code> and every mute character all become
+the click. See <a href="editor.html">the editor path</a>.
+<span class="src"><code>m_msg_sound.c_inc:26</code></span></p>
+
+<div class="note">
+<strong>Only these 34 values leave the text layer.</strong> Word boundaries, capitalisation,
+sentence structure and the actual letters beyond <code>a-z0-9</code> are all gone by
+the time audio sees anything. Whatever "understanding of English" the animalese has,
+it lives entirely in the digraph table on the next page.
+</div>
+
+<div class="pager">
+ <span><a href="gating.html">← 1. Gating</a></span>
+ <span><a href="scheduling.html">3. Windowing &amp; timing →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/gating.html b/docs/gating.html
new file mode 100644
index 0000000..9e78ac0
--- /dev/null
+++ b/docs/gating.html
@@ -0,0 +1,180 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>1. Gating &amp; voice selection — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 1. Gating &amp; voice selection</nav>
+
+<h1>1. Gating &amp; voice selection</h1>
+<p class="lede">Before a single character is converted, two decisions are made: whether
+this message speaks at all, and with whose voice.</p>
+
+<h2>Voice mode</h2>
+
+<p>There are three voice modes, pushed to the audio engine by
+<code>sAdo_SetVoiceMode</code> and stored in <code>sou_voice_mode</code>:</p>
+
+<table>
+<tr><th>Mode</th><th>Name</th><th>Behaviour</th></tr>
+<tr><td><code>0</code></td><td><code>VOICE_MODE_ANIMALESE</code></td><td>full phonology, samples from the voice bank</td></tr>
+<tr><td><code>1</code></td><td><code>VOICE_MODE_CLICK</code></td><td>every non-space character plays <code>NA_SE_BEBE</code>, one blip</td></tr>
+<tr><td><code>2</code></td><td><code>VOICE_MODE_SILENT</code></td><td>nothing</td></tr>
+</table>
+
+<p>The mode is not simply the player's config setting. It is the product of the setting
+and of <em>who is talking</em> — <code>mMsg_sound_voice_mode_get</code>:</p>
+
+<pre>mode_type = Save_Get(config).voice_mode // 0 animalese, 1 bebese, 2 silent
+if (msg has VOICE_SILENT flag) mode_type = SILENT
+
+if (mMsg_check_sound_animal(msg))
+ mode = { ANIMALESE, CLICK, SILENT }[mode_type] // an animal is speaking
+else if (mode_type == SILENT)
+ mode = SILENT
+else
+ mode = CLICK // narration, signs, menus</pre>
+
+<p class="src">Source: <code>src/game/m_msg_sound.c_inc:259</code></p>
+
+<p>So the config's "animalese" setting only unlocks animalese for messages that pass
+<code>mMsg_check_sound_animal</code>. Everything else — item descriptions, bulletin
+boards, system prompts — is click mode no matter what.</p>
+
+<h3>What counts as an animal talking</h3>
+
+<p><code>mMsg_check_sound_animal</code> (<code>m_msg_sound.c_inc:66</code>) returns true when the
+<code>VOICE_CLICK</code> flag is clear <em>and</em> any of:</p>
+
+<ul>
+ <li><code>msg_p-&gt;show_actor_name</code> — the window has a speaker nameplate;</li>
+ <li><code>mMsg_check_sound_special</code> — message number in
+ <code>0x1743–0x1756</code> (event start), <code>0x1799–0x17AC</code> (event end),
+ or <code>0x3A8A–0x3AF0</code> (Dial-a-Psychic);</li>
+ <li><code>mMsg_check_sound_shasho</code> — message number in <code>0x35AF–0x35BE</code>
+ (the conductor).</li>
+</ul>
+
+<p>The special ranges exist so that disembodied event narration still gets a voice.
+They are forced to <strong>spec 4</strong> regardless of any actor
+(<code>mMsg_sound_spec_change_voice_force</code>, <code>:288</code>).</p>
+
+<h2>Spec: which of the three voices</h2>
+
+<p>The speaker maps to a "spec" (2–9), and the spec selects a sequence and bank.
+<code>Na_SpecChange</code> (<code>game64.c_inc:3459</code>) is the switch, and it is a no-op
+if the spec is unchanged — otherwise it stops the voice sequence, clears the stay
+cache, and starts a new one:</p>
+
+<table>
+<tr><th>Spec</th><th>Sequence</th><th>Bank</th><th>Instruments</th><th>Used by</th></tr>
+<tr><td>2, 5, 7, 9</td><td>243</td><td>156</td><td>119</td><td>default villagers, most specials, male player, Timmy/Tommy, Farley</td></tr>
+<tr><td>3</td><td>244</td><td>157</td><td>83</td><td>Wendell, Pete, Booker, Jack</td></tr>
+<tr><td>4, 6, 8</td><td>245</td><td>158</td><td>119</td><td>Pelly, Phyllis, Mabel, Sable, Blanca, female player, Joan, event narration</td></tr>
+</table>
+
+<p>Spec also selects a base timbre inside <code>Na_VoiceSe</code>
+(<code>game64.c_inc:2760</code>), reapplied on every call:</p>
+
+<table>
+<tr><th>Spec</th><th>Volume (<code>_08</code>)</th><th>Pitch (<code>_14</code>)</th><th>Character</th></tr>
+<tr><td>2, 3, 4</td><td>0.65</td><td>1.00</td><td>baseline</td></tr>
+<tr><td>5, 6</td><td>0.90</td><td>1.00</td><td>player (louder, unmodulated)</td></tr>
+<tr><td>7</td><td>0.90</td><td>1.30</td><td>squeaky — Timmy &amp; Tommy</td></tr>
+<tr><td>8</td><td>0.90</td><td>0.75</td><td>low — Joan</td></tr>
+<tr><td>9</td><td>0.70</td><td>0.65</td><td>very low — Farley</td></tr>
+</table>
+
+<p>Note that <strong>spec is orthogonal to bank</strong>: specs 5, 7 and 9 all use bank 156 and
+differ only in this pitch/volume pair. There are three sample sets and eight voices.</p>
+
+<h3>Villagers</h3>
+
+<p><code>mNpc_GetNpcSoundSpec</code> (<code>src/game/m_npc.c:4116</code>) indexes by personality:</p>
+
+<pre>static int spec_table[mNpc_LOOKS_NUM] = { 4, 4, 2, 2, 3, 4 };
+// normal(girl) 4 | peppy 4 | lazy(boy) 2 | jock 2 | cranky 3 | snooty 4</pre>
+
+<p>An out-of-range <code>looks</code> falls back to <code>mNpc_LOOKS_BOY</code> (spec 2).</p>
+
+<h3>Special NPCs</h3>
+
+<p>Non-villagers fall through to <code>mNpc_GetNpcSoundSpecNotAnimal</code>, a linear scan of
+<code>l_sp_actor_name</code> (<code>m_npc.c:32</code>). The default when not found is spec&nbsp;2.
+Everything in that table is spec 2 except:</p>
+
+<table>
+<tr><th>Spec</th><th>NPCs</th></tr>
+<tr><td>3</td><td>Wendell, Pete, Booker, Jack</td></tr>
+<tr><td>4</td><td>Pelly, Phyllis, Mabel, Sable, Blanca</td></tr>
+<tr><td>8</td><td>Joan</td></tr>
+<tr><td>9</td><td>Farley</td></tr>
+</table>
+
+<h3>The player's own voice: specs 5 and 6</h3>
+
+<p>Specs 5 and 6 never appear in any NPC table. They are set from the submenu
+(<code>src/game/m_submenu.c:364</code>) when a menu that can type text opens:</p>
+
+<pre>if ((menu_type == mSM_OVL_LEDIT &amp;&amp; param0 == 0) ||
+ Common_Get(now_private)-&gt;gender == mPr_SEX_MALE) {
+ sAdo_SpecChange(5); // male
+} else {
+ sAdo_SpecChange(6); // female
+}
+sAdo_SetVoiceMode(0); // force animalese on regardless of config</pre>
+
+<p>Two things follow. First, letter-writing and name entry force animalese even when the
+player has set the config to click or silent. Second, specs 5 and 6 are treated as a
+distinct class throughout <code>Na_VoiceSe</code>:</p>
+
+<ul>
+ <li>every punctuation code (<code>0x81</code>, <code>0x84</code>, <code>0x85</code>,
+ <code>0x86</code>) plays <code>NA_SE_BEBE</code> instead of being silently dropped —
+ the typing click;</li>
+ <li>the <code>?</code> pitch bump and <code>!</code> volume bump never apply;</li>
+ <li>the emotion LFO is skipped entirely;</li>
+ <li>anti-repetition substitution is disabled.</li>
+</ul>
+
+<p>See <a href="editor.html">the editor path</a> for what actually gets spoken there.</p>
+
+<h3>Timmy and Tommy</h3>
+
+<p>Two text control codes toggle a temporary spec override, used for Nook's nephews:</p>
+
+<table>
+<tr><th>Code</th><th>Value</th><th>Effect</th></tr>
+<tr><td><code>mFont_CONT_CODE_VOICE_TRUE</code></td><td>92 (0x5C)</td><td>restore <code>msg_p-&gt;spec</code></td></tr>
+<tr><td><code>mFont_CONT_CODE_VOICE_FALSE</code></td><td>93 (0x5D)</td><td>switch to spec 7 (pitch × 1.3)</td></tr>
+</table>
+
+<p>Both guard on the actor being <code>SP_NPC_MAMEDANUKI0/1</code>, so the codes are inert in
+any other message. <span class="src"><code>m_msg_sound.c_inc:317–340</code></span></p>
+
+<h2>Hard gates inside Na_VoiceSe</h2>
+
+<p>Four early returns drop the call regardless of everything above
+(<code>game64.c_inc:2724–2735</code>):</p>
+
+<ul>
+ <li><code>voice &gt; 0x12B</code> — NPC voice id out of range (299 max);</li>
+ <li>animalese mode and <code>Nap_ReadSubPort(VOICE_GROUP, 0, 1) != sou_now_voice_seq</code>
+ — the voice sequence has not finished loading yet, so early characters of the
+ first line after a spec change are silently dropped;</li>
+ <li><code>sou_scene_mode == 0</code> — audio not in a playing scene;</li>
+ <li><code>a == 0x85 &amp;&amp; b == 0xFF &amp;&amp; c == 0xFF</code> — a lone mute character,
+ handled at the very top before any state is touched.</li>
+</ul>
+
+<div class="pager">
+ <span><a href="index.html">← Index</a></span>
+ <span><a href="encoding.html">2. Text → voice code →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..d6a0817
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,101 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Animalese pipeline — Animal Crossing (GC)</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<h1>Animalese: the text&nbsp;→&nbsp;speech pipeline</h1>
+<p class="lede">How <em>Animal Crossing</em> (GameCube, <code>GAFE01</code>, USA rev&nbsp;0) turns a
+message string into villager babble. Every claim here is traced to a function in the
+<a href="https://github.com/Prakxo/ac-decomp">ac-decomp</a> tree.</p>
+
+<h2>The short version</h2>
+
+<pre>
+message text (game charset, 1 byte/char)
+ │
+ │ 1. mMsg_sound_voice_get() 256-entry table
+ ▼ char code ──► voice code (0x53–0x86)
+gating + voice selection
+ │ 2. mMsg_sound_voice_mode_get() animalese / click / silent
+ │ mNpc_GetNpcSoundSpec() speaker ──► spec 2–9
+ ▼ Na_SpecChange() spec ──► sequence 243/244/245
+windowing
+ │ 3. mMsg_sound_voice_entry() collect (a, b, c) over two cursor steps
+ ▼ ~1 character per 2 game frames
+draw-time trigger
+ │ 4. mFontSentence_animal_voice_se() glyph scale ──► volume
+ ▼ sAdo_VoiceSe(a, b, c, npc, scale, mode)
+phonology
+ │ 5. Na_VoiceSe() digraphs, long vowels, silent e
+ ▼ ──► phoneme code 0x01–0x77
+playback
+ │ 6. Sou_VoiceStart() write port 0 of a JAudio2 subtrack
+ │ sequence dyntbl ──► instrument (code − 1)
+ ▼ 7. ProgToVp(bank, inst) ──► NoteToVoice() ──► smzwavetable
+DSP ──► one ~190 ms VADPCM sample, 22050 Hz
+</pre>
+
+<p>A phoneme code is literally a <strong>soundfont instrument number plus one</strong>. The whole
+"engine" is a lookup table with about fifty special cases; the character comes almost
+entirely from the samples.</p>
+
+<h2>Pages</h2>
+
+<ul class="toc">
+ <li><a href="gating.html">1. Gating &amp; voice selection</a>
+ <span>When animalese plays at all, and which of the three voices you hear.
+ Specs 2–9, the click fallback, and the player's own voice.</span></li>
+
+ <li><a href="encoding.html">2. Text → voice code</a>
+ <span>The game's character set, the 256-entry voice table, case folding,
+ accent folding, and the eight punctuation codes.</span></li>
+
+ <li><a href="scheduling.html">3. Windowing &amp; timing</a>
+ <span>How three characters get bundled into one call, why it fires from the
+ font renderer, and the every-other-frame cursor.</span></li>
+
+ <li><a href="phonology.html">4. The phonology engine</a>
+ <span><code>Na_VoiceSe</code> in full: the 55-entry digraph table, short/long
+ vowel tables, silent&nbsp;e, magic&nbsp;e, and punctuation flush.</span></li>
+
+ <li><a href="playback.html">5. Playback</a>
+ <span>Sequences, banks, the two alternating subtracks, per-spec timbre,
+ the emotion LFO, and the deferred second syllable.</span></li>
+
+ <li><a href="soundfont.html">6. The soundfont lookup</a>
+ <span>Sequences, banks and instruments as data: the four archive tables, the
+ bank blob, load-time relocation, and where the PCM actually lives.</span></li>
+
+ <li><a href="editor.html">7. The editor path</a>
+ <span>Mode&nbsp;1 — an undocumented third calling convention that bypasses
+ phonology entirely, and what it says about samples 0x53–0x76.</span></li>
+
+ <li><a href="quirks.html">8. Quirks, dead code &amp; corrections</a>
+ <span>The dead volume store, the tautological dedup, vestigial mouth-shape
+ tracking, and corrections to earlier notes.</span></li>
+
+ <li><a href="reference.html">9. Reference tables</a>
+ <span>Everything in one place: voice codes, digraphs, phoneme tables,
+ control codes, source line index.</span></li>
+</ul>
+
+<h2>Scope</h2>
+<p>These pages document the <em>runtime</em> pipeline as decompiled. Extracting the
+actual audio samples from a disc image is covered separately in
+<code>../extraction.md</code>, which also carries the audio table-of-contents,
+bank/sample formats, and VADPCM decoding notes.</p>
+
+<div class="note">
+<strong>No game assets here.</strong> Everything on these pages is derived from decompiled
+source. Reproducing audible animalese needs a legally dumped copy of the game.
+</div>
+
+<p class="src">Verified against ac-decomp at commit <code>09ca8e8b</code> (DOL and REL 100 % linked).</p>
+
+</body>
+</html>
diff --git a/docs/phonology.html b/docs/phonology.html
new file mode 100644
index 0000000..ebbc872
--- /dev/null
+++ b/docs/phonology.html
@@ -0,0 +1,235 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>4. The phonology engine — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 4. The phonology engine</nav>
+
+<h1>4. The phonology engine</h1>
+<p class="lede"><code>Na_VoiceSe</code> — <code>src/static/jaudio_NES/game/game64.c_inc:2598</code>.
+Given three voice codes it produces one phoneme code now and optionally queues a second
+for the next frame.</p>
+
+<pre>void Na_VoiceSe(u8 a, u8 b, u8 c, s16 voice, u8 scale, u8 f)</pre>
+
+<p><code>f</code> is the calling mode: <strong>0</strong> is the normal message path,
+<strong>2</strong> is the deferred second syllable, <strong>1</strong> is the
+<a href="editor.html">editor path</a> which skips this page entirely.</p>
+
+<h2>Vowel / consonant classification</h2>
+
+<p><code>Sou_BoinShiinCheck</code> (<code>:2564</code>) — <em>boin</em> = vowel,
+<em>shiin</em> = consonant:</p>
+
+<table>
+<tr><th>Returns</th><th>Class</th><th>Members</th></tr>
+<tr><td><code>0</code></td><td>vowel</td><td><code>a e i o u</code> <strong>and <code>n</code></strong></td></tr>
+<tr><td><code>1</code></td><td>consonant</td><td>the other 20 letters</td></tr>
+<tr><td><code>2</code></td><td>neither</td><td>digits and every punctuation code</td></tr>
+</table>
+
+<p><code>n</code> being a vowel is deliberate — it is a syllabic nasal in Japanese, and this
+engine was written by Japanese programmers modelling English through a kana-shaped
+lens. It changes when the silent-<em>e</em> and magic-<em>e</em> rules fire, and it is
+the single most surprising thing in the engine.</p>
+
+<h2>Rule order for mode 0</h2>
+
+<p>State is carried in <code>sou_last_num</code> and <code>sou_last_num2</code>, the previous
+call's raw <code>a</code> and <code>b</code>. The originals are also stashed in
+<code>sou_num_org</code>/<code>sou_num2_org</code>/<code>sou_num3_org</code> before anything is
+rewritten.</p>
+
+<h3>0. Lone mute</h3>
+<pre>if (a == 0x85 &amp;&amp; b == 0xFF &amp;&amp; c == 0xFF) return;</pre>
+<p>Returns before touching any state, so a lone punctuation mark does not disturb the
+previous-character history.</p>
+
+<h3>1. Silent e (<code>:2612</code>)</h3>
+<pre>if (a == 'e' &amp;&amp; b &gt; 0x77 &amp;&amp; class(last_num) == vowel &amp;&amp; class(last_num2) == consonant) {
+ a = b; r28 = 4; // drop the e, speak the punctuation slot
+}
+if (b == 'e' &amp;&amp; c &gt; 0x77 &amp;&amp; class(last_num2) == vowel &amp;&amp; class(a) == consonant) {
+ b = 0; r28 = 4; // drop the e, queue nothing
+}</pre>
+<p>A word-final <em>e</em> preceded by vowel + consonant is dropped: <em>make</em>,
+<em>time</em>, <em>nose</em>. Because <code>n</code> classifies as a vowel, <em>done</em> and
+<em>gone</em> also trip it. Setting <code>r28 = 4</code> skips the digraph pass entirely.</p>
+
+<h3>2. Digraph merge (<code>:2623</code>)</h3>
+<pre>a = Sou_ConnectCheck(a, b, c);
+r28 = (a == sou_num_org) ? 1 : 2; // 2 = merged, both letters consumed</pre>
+<p>If a digraph matched, <code>r28 = 2</code> and control falls straight through to
+playback: the merged phoneme plays and <strong>nothing is queued</strong>, so both letters
+are consumed by one sample.</p>
+
+<h4>The digraph table — <code>Sou_ConnectCheck</code> (<code>:2316</code>)</h4>
+<p>53 entries. All are two characters except <code>you</code>, the only rule that reads <code>c</code>; one pairs a letter with a space rather than another letter (<code>i␣</code>).</p>
+
+<div class="grid2">
+<code>ai</code>→0x0F<br><code>al</code>→0x02<br><code>an</code>→0x39<br><code>ar</code>→0x16<br><code>as</code>→0x3A<br>
+<code>be</code>→0x3B<br><code>by</code>→0x3C<br>
+<code>ch</code>→0x08<br>
+<code>do</code>→0x3D<br>
+<code>ea</code>→0x15<br><code>er</code>→0x0D<br>
+<code>fa</code>→0x35<br><code>fo</code>→0x34<br>
+<code>ge</code>→0x17<br><code>go</code>→0x3E<br>
+<code>ha</code>→0x3F<br><code>he</code>→0x40<br><code>hi</code>→0x41<br><code>ho</code>→0x42<br>
+<code>i␣</code>→0x05<br><code>if</code>→0x43<br><code>in</code>→0x44<br><code>ir</code>→0x0C<br><code>is</code>→0x45<br><code>it</code>→0x46<br>
+<code>ks</code>→0x31<br>
+<code>me</code>→0x47<br><code>my</code>→0x48<br>
+<code>na</code>→0x38<br><code>ng</code>→0x1C<br><code>no</code>→0x49<br>
+<code>of</code>→0x4A<br><code>oh</code>→0x4B<br><code>ok</code>→0x4C<br><code>on</code>→0x4D<br><code>oo</code>→0x2A<br><code>or</code>→0x2C<br><code>ou</code>→0x20<br><code>ow</code>→0x06<br><code>oy</code>→0x1F<br>
+<code>re</code>→0x0E<br>
+<code>se</code>→0x37<br><code>sh</code>→0x24<br><code>si</code>→0x36<br><code>so</code>→0x4F<br><code>su</code>→0x25<br>
+<code>th</code>→0x28<br><code>to</code>→0x50<br>
+<code>up</code>→0x51<br>
+<code>we</code>→0x52<br><code>wo</code>→0x33<br>
+<code>you</code>→0x77<br>
+<code>ze</code>→0x32
+</div>
+
+<p>The design is transparent. Fourteen entries are genuine English digraphs —
+<code>ch sh th ng oo ow ou oy ai ea ar er ir or</code>. The other thirty-nine are the
+openings of common English words: <code>he ho hi ha me my no on of oh ok so to up we
+by do go is it in if as an al na se si su fa fo ge ze ks you</code>. The engine does not
+model English phonetics; it memorises the most frequent English word-starts and gives
+each one a recorded syllable.</p>
+
+<h3>3. Punctuation flush (<code>:2630</code>)</h3>
+<pre>if (b is punctuation) {
+ r28 = 3;
+ if (sou_last_num2 was also punctuation) r28 = 1; // don't flush twice
+}</pre>
+<p>When a word ends, <code>r28 = 3</code> re-runs the merge one character further back:</p>
+<pre>a = Sou_ConnectCheck(sou_last_num2, sou_num_org, sou_num2_org);
+if (a &lt; 0x53 || a &gt; 0x76) break; // it merged, or wasn't a letter — leave it
+a = Sou_TanboinHenkan(a, b);</pre>
+<p>This gives the final letter of a word a chance to pair with the letter before it
+instead of being dropped against the following space. Note the guard also drops the
+whole branch if <code>a &gt; 0x77</code> before it starts.</p>
+
+<h3>4. Vowel length (<code>:2658</code>, for <code>r28 == 1</code> or <code>4</code>)</h3>
+<pre>if (class(a) == vowel &amp;&amp; class(b) == consonant &amp;&amp; c == 'e')
+ a = Sou_ChouboinHenkan(a, b); // long — magic e, as in cake
+else
+ a = Sou_TanboinHenkan(a, b); // short
+
+sou_num2_request = b; // queue the second syllable
+sou_num2_animal_id = voice;
+sou_num2_scale = scale;</pre>
+
+<p><em>Chouboin</em> = long vowel, <em>tanboin</em> = short vowel. The two tables are
+identical except for five entries:</p>
+
+<table>
+<tr><th></th><th><code>a</code></th><th><code>i</code></th><th><code>u</code></th><th><code>e</code></th><th><code>o</code></th><th><code>n</code></th></tr>
+<tr><td>short</td><td>0x01</td><td>0x14</td><td>0x29</td><td>0x0A</td><td>0x1D</td><td>0x1B</td></tr>
+<tr><td>long</td><td>0x02</td><td>0x15</td><td>0x2A</td><td>0x0E</td><td>0x1E</td><td>0x1B</td></tr>
+</table>
+
+<p><code>n</code> is the same in both — it is only classified as a vowel, it has no long form.
+Note the aliasing: long <code>a</code> (0x02) is the same sample as digraph <code>al</code>,
+long <code>i</code> (0x15) is <code>ea</code> and consonant <code>y</code>, long <code>e</code>
+(0x0E) is <code>re</code>, long <code>u</code> (0x2A) is <code>oo</code>.</p>
+
+<h4>Consonants (both tables)</h4>
+<table>
+<tr><th>Letter</th><th>Code</th><th>Letter</th><th>Code</th><th>Letter</th><th>Code</th><th>Letter</th><th>Code</th></tr>
+<tr><td><code>b</code></td><td>0x07</td><td><code>g</code></td><td>0x12</td><td><code>m</code></td><td>0x1A</td><td><code>t</code></td><td>0x26</td></tr>
+<tr><td><code>c</code></td><td>0x23 / 0x18</td><td><code>h</code></td><td>0x00</td><td><code>p</code></td><td>0x21</td><td><code>v</code></td><td>0x2D</td></tr>
+<tr><td><code>d</code></td><td>0x09</td><td><code>j</code></td><td>0x17</td><td><code>q</code></td><td>0x18</td><td><code>w</code></td><td>0x2E</td></tr>
+<tr><td><code>f</code></td><td>0x11</td><td><code>k</code></td><td>0x18</td><td><code>r</code></td><td>0x22</td><td><code>x</code></td><td>0x31</td></tr>
+<tr><td></td><td></td><td><code>l</code></td><td>0x19</td><td><code>s</code></td><td>0x23</td><td><code>y</code></td><td>0x15</td></tr>
+<tr><td></td><td></td><td></td><td></td><td></td><td></td><td><code>z</code></td><td>0x30</td></tr>
+</table>
+
+<p>Only one context rule exists: <code>c</code> is soft (0x23, same as <code>s</code>) when the
+<em>next</em> letter is <code>i</code> or <code>e</code>, hard (0x18, same as <code>k</code>)
+otherwise. <code>h</code> maps to 0x00, which has no instrument — <strong><code>h</code> is
+silent</strong> unless it merges into a digraph.</p>
+
+<h4>Digits</h4>
+<table>
+<tr><th><code>0</code></th><th><code>1</code></th><th><code>2</code></th><th><code>3</code></th><th><code>4</code></th><th><code>5</code></th><th><code>6</code></th><th><code>7</code></th><th><code>8</code></th><th><code>9</code></th></tr>
+<tr><td>0x32</td><td>0x33</td><td>0x26</td><td>0x27</td><td>0x34</td><td>0x35</td><td>0x36</td><td>0x37</td><td>0x10</td><td>0x38</td></tr>
+</table>
+<p>Eight of the ten reuse a code the letter tables also produce, and the overlaps line up
+with the first syllable of the spoken number: <code>0</code>=<code>ze</code>(ro),
+<code>1</code>=<code>wo</code>(one), <code>2</code>=<code>t</code>(wo),
+<code>4</code>=<code>fo</code>(ur), <code>5</code>=<code>fa</code>(five),
+<code>6</code>=<code>si</code>(x), <code>7</code>=<code>se</code>(ven),
+<code>9</code>=<code>na</code>(nine). Only <code>3</code> (0x27) and <code>8</code> (0x10)
+get codes nothing else reaches. The digraph names here are just labels for shared
+samples — the recording itself presumably says the number.</p>
+
+<h2>The deferred second syllable — mode 2</h2>
+
+<p><code>Sou_SpecialRoutine10</code> (<code>:1708</code>) runs once per game frame inside
+<code>Na_GameFrame</code>:</p>
+
+<pre>if (sou_Na_VoiceSeFlag == 1) {
+ sou_Na_VoiceSeFlag = 0; // a real call happened this frame
+} else if (sou_num2_request) {
+ Na_VoiceSe(sou_num2_request, 0, 0, sou_num2_animal_id, sou_num2_scale, 2);
+ sou_num2_request = 0;
+}</pre>
+
+<p>So the queued <code>b</code> plays on the first frame where no fresh call arrived. Mode 2
+is a stripped-down path:</p>
+
+<pre>if (sou_voice_mode == 1) return; // click mode: no second syllable
+if (a == 0xFF) return;
+if (a == 'e' &amp;&amp; sou_num3_org &gt; 0x77 &amp;&amp;
+ class(sou_last_num2) == vowel &amp;&amp; class(sou_num_org) == consonant)
+ a = 0; // silent-e check again
+else
+ a = Sou_TanboinHenkan(a, sou_num3_org); // always short</pre>
+
+<p>The queued syllable never gets the long-vowel treatment and never re-checks digraphs,
+which is why the second half of a pair always sounds flatter than the first.</p>
+
+<h2>Anti-repetition</h2>
+
+<pre>// mode 0
+if (sou_num_org == sou_last_num2 &amp;&amp; sou_num_org == sou_last_num) i = 1;
+…
+if (spec == 5 || spec == 6 || r29 &gt; 0x77) i = 0;
+if (i == 1) a = Nap_GetRandom() &gt;&gt; 2; // 0x00–0x3F</pre>
+
+<p>Three identical voice codes in a row — <code>aaa</code>, <code>mmm</code>, <code>zzz</code> —
+substitute a uniformly random phoneme from the first 64. This is the only path that
+reaches many of the codes never produced by any rule, and it is why repeated-letter
+interjections in dialogue sound like nonsense rather than a stutter. It is disabled
+for the player's voice (specs 5 and 6).</p>
+
+<h2>♪ — the melody escape</h2>
+
+<pre>if (a == 0x80) {
+ if (voice == 0xFA) return;
+ switch (sou_voice_mode) {
+ case 0: Na_MelodyVoice(voice); break; // animalese
+ case 1: Na_SysTrgStart(NA_SE_BEBE); break; // click
+ case 2: break; // silent
+ }
+ return;
+}</pre>
+
+<p><code>Na_MelodyVoice</code> (<code>src/static/jaudio_NES/game/melody.c:727</code>) plays a
+fixed 16-note pattern <code>{13, 14 × 15}</code> on subtrack 6 at volume 1.3, using the
+speaker's <code>npc_voice_id</code> to select which chunk of sequence 248 to copy in. The
+id is the NPC's instrument type (<code>draw_data.voice_type</code>), the same one used for
+the town tune — so ♪ plays "their" instrument rather than a phoneme.</p>
+
+<div class="pager">
+ <span><a href="scheduling.html">← 3. Windowing &amp; timing</a></span>
+ <span><a href="playback.html">5. Playback →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/playback.html b/docs/playback.html
new file mode 100644
index 0000000..1cf9464
--- /dev/null
+++ b/docs/playback.html
@@ -0,0 +1,196 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>5. Playback — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 5. Playback</nav>
+
+<h1>5. Playback</h1>
+<p class="lede">A phoneme code becomes an instrument number, written into a port on a
+looping JAudio2 sequence. Everything expressive happens through two float multipliers.</p>
+
+<h2>Punctuation, applied at play time</h2>
+
+<p>By the time the rule engine finishes, punctuation codes are still in <code>a</code>.
+The <code>sou_voice_mode</code> switch (<code>game64.c_inc:2810</code>) resolves them, and
+splits on whether this is the player's voice:</p>
+
+<h3>Specs 5 and 6 — the player</h3>
+<pre>switch (a) {
+ case 0x81: case 0x84: case 0x85: case 0x86:
+ Na_SysTrgStart(NA_SE_BEBE); a = 0; break;
+}</pre>
+<p>Every punctuation code clicks. <code>?</code> and <code>!</code> fall through unmodified
+into the phoneme path — the pitch and volume bumps below never apply to the player.</p>
+
+<h3>All other specs</h3>
+<table>
+<tr><th>Code</th><th>Char</th><th>Effect</th></tr>
+<tr><td><code>0x86</code></td><td>click</td><td><code>NA_SE_BEBE</code>, then mute</td></tr>
+<tr><td><code>0x81</code> <code>0x84</code> <code>0x85</code></td><td>space, newline, mute</td><td>mute</td></tr>
+<tr><td><code>0x82</code></td><td><code>?</code></td><td>mute, and pitch × 1.24 / 1.30 / 1.13 for sequence 243 / 244 / 245</td></tr>
+<tr><td><code>0x83</code></td><td><code>!</code></td><td>mute, and volume × 1.15</td></tr>
+<tr><td>anything else</td><td></td><td>plays; sets <code>sou_now_boin</code> (dead, see <a href="quirks.html">quirks</a>)</td></tr>
+</table>
+
+<p>Both multipliers are applied to the spec's <em>base</em> value, which was just reset at
+the top of the call, so they last exactly one utterance. The pitch factor differing per
+sequence is a per-bank tuning correction, not an expressive choice.</p>
+
+<h2>The emotion LFO</h2>
+
+<p>Six emotion states are set by text control codes and stored in
+<code>sou_message_status</code>:</p>
+
+<table>
+<tr><th>Control code</th><th>Value</th><th>State</th></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_NORMAL</code></td><td>75</td><td><code>VOICE_STATUS_NORMAL</code></td></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_ANGRY</code></td><td>76</td><td><code>VOICE_STATUS_ANGRY</code></td></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_SAD</code></td><td>77</td><td><code>VOICE_STATUS_SAD</code></td></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_FUN</code></td><td>78</td><td><code>VOICE_STATUS_FUN</code></td></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_SLEEPY</code></td><td>79</td><td><code>VOICE_STATUS_SLEEPY</code></td></tr>
+<tr><td><code>SET_MESSAGE_CONTENTS_GLOOMY</code></td><td>97</td><td><code>VOICE_STATUS_GLOOMY</code></td></tr>
+</table>
+
+<p>Each drives a short random-period modulation of <code>_18</code> (pitch multiplier) and
+<code>_0C</code> (volume multiplier), recomputed per utterance
+(<code>game64.c_inc:2880</code>). It is skipped for punctuation
+(<code>r29 &gt; 0x77</code>) and for specs 5 and 6, in which case the previous values
+persist.</p>
+
+<h3>Angry (1)</h3>
+<pre>if (counter == 0) counter = (rand() &gt;&gt; 6) + 10; // 10–13
+else counter--;
+switch (counter) {
+ case 0: case 1: case 2: pitch = 1.25, vol = 1.25; break;
+ case 3: case 4: pitch = 1.20, vol = 1.20; break;
+ case 9: case 10: pitch = 1.25, vol = 1.25; break;
+ default: pitch = 0.90, vol = 1.05; break;
+}</pre>
+<p>Mostly flat and slightly quiet, with two bursts near the top and bottom of each
+10–13 syllable cycle — a jagged, shouty contour.</p>
+
+<h3>Sad (2), Sleepy (4), Gloomy (5)</h3>
+<pre>if (counter == 0) { effect = (rand() &gt;&gt; 6) + 14; counter = 1; } // 14–17
+else if (counter == effect) { counter = 0; sad_toguru ^= 1; }
+else counter++;
+
+pitch = (sad_toguru ? 1.1 : 1.0) - counter * 0.022;
+vol = (sad_toguru ? 1.1 : 1.0) - counter * 0.025;</pre>
+<p>A sawtooth: pitch and volume slide down over 14–17 syllables, then jump back up,
+alternating between two baselines. The three states share the code exactly — they
+differ only in what the writers chose to tag.</p>
+
+<h3>Fun (3)</h3>
+<pre>if (counter == 0) counter = (rand() &gt;&gt; 6) + 10;
+else counter--;
+switch (counter) {
+ case 0: case 1: pitch = 1.05, vol = 1.05; break;
+ case 2: pitch = 0.90, vol = 1.15; break;
+ default: pitch = 1.15, vol = 1.05; break;
+}</pre>
+<p>Consistently bright with a dip at the end of each cycle.</p>
+
+<h3>Normal (0)</h3>
+<pre>pitch = 1.0; vol = 1.0;</pre>
+
+<h2>Reaching the DSP</h2>
+
+<p>Two subtracks in <code>NA_GROUP4</code> alternate, toggled by
+<code>sou_voice_se_toguru</code> on every call, so consecutive syllables can overlap
+rather than cutting each other off:</p>
+
+<pre>void Sou_VoiceStart(u8 subtrack, u8 b) {
+ NA_COMMAND_AUDIO_SUBTRACK_SET_PORT(VOICE_GROUP, subtrack, 0, b);
+}</pre>
+
+<p>The guard before it is <code>if (a != 0 &amp;&amp; a &lt;= 0x77)</code> — code 0 never plays,
+which is why <code>h</code> is silent. The call is also skipped entirely when the raw
+code was space, newline or mute (<code>r29 != 0x85 &amp;&amp; r29 != 0x81 &amp;&amp;
+r29 != 0x84</code>).</p>
+
+<h3>The sequence side</h3>
+
+<p>Each voice sequence's subtrack sits in a spin loop:</p>
+
+<pre>L_0019: delay 1 frame
+ macro_read_port0
+ branch_rel_not_eq_zero L_0019 ; wait until Sou_VoiceStart writes the port
+ set_dyntbl D_0026
+ dyntbl_call ; index the table with the phoneme code
+ branch L_0019</pre>
+
+<p>The dynamic table has 128 entries pointing at 10-byte routines
+<code>C1 &lt;instrument&gt; / 88 &lt;note&gt; / FF</code>. Entry <em>v</em> selects instrument
+<strong><em>v</em> − 1</strong>. Entry 0 is an immediate <code>FF</code> — nothing.</p>
+
+<h3>Per-frame parameter push</h3>
+
+<p><code>Sou_VoiceMake</code> (<code>game64.c_inc:1174</code>) runs for both subtracks once
+per game frame, inside <code>Na_GameFrame</code>:</p>
+
+<pre>volumeScale = _08 * _0C; // spec base × emotion volume
+frequencyScale = _14 * _18; // spec base × emotion pitch
+NA_COMMAND_AUDIO_SUBTRACK_SET_VOL_SCALE (VOICE_GROUP, index, volumeScale);
+NA_COMMAND_AUDIO_SUBTRACK_SET_FREQ_SCALE(VOICE_GROUP, index, frequencyScale);
+NA_COMMAND_AUDIO_SUBTRACK_SET_PAN (VOICE_GROUP, index, pan);
+NA_COMMAND_AUDIO_SUBTRACK_REVERB_VOLUME (VOICE_GROUP, index, reverbVolume);</pre>
+
+<p><code>pan</code> is initialised to <code>0x3F</code> (centre) and <code>reverbVolume</code> to 0,
+and neither is ever written for voice subtracks. <strong>Animalese is always centred, dry,
+and unaffected by the speaker's distance from the camera</strong> — unlike every other
+positional SE in the game.</p>
+
+<p>Because both subtracks share <code>sou_voice_se[0]</code> and <code>[1]</code> which are
+always written together, an in-flight syllable's volume and pitch are retroactively
+changed by the next syllable's settings.</p>
+
+<h3>Frame ordering</h3>
+
+<p><code>graph.c</code> runs <code>game_main</code> (including the draw pass that calls
+<code>Na_VoiceSe</code>) and <em>then</em> <code>sAdo_GameFrame</code>. So within one frame:</p>
+
+<pre>game_main → mFontSentence_animal_voice_se → Na_VoiceSe (sets params, writes port)
+Na_GameFrame → Sou_VoiceMake (pushes params to the DSP)
+ → Sou_SpecialRoutine10 (fires any queued second syllable)</pre>
+
+<p><code>Sou_VoiceMake</code> runs <em>before</em> <code>Sou_SpecialRoutine10</code>, so a
+deferred syllable's parameters land one frame late.</p>
+
+<h2>Banks</h2>
+
+<p>From <code>src/static/jaudio_NES/game/audioheaders.c</code>:</p>
+
+<table>
+<tr><th>Bank</th><th>ROM addr</th><th>Size</th><th>param0</th><th>param1</th><th>Instruments</th></tr>
+<tr><td>156</td><td><code>0x0005C260</code></td><td><code>0x4500</code></td><td><code>0x05FF</code></td><td><code>0x7700</code></td><td>119</td></tr>
+<tr><td>157</td><td><code>0x00060760</code></td><td><code>0x3020</code></td><td><code>0x05FF</code></td><td><code>0x5300</code></td><td>83</td></tr>
+<tr><td>158</td><td><code>0x00063780</code></td><td><code>0x4500</code></td><td><code>0x05FF</code></td><td><code>0x7700</code></td><td>119</td></tr>
+</table>
+
+<p><code>param1 &gt;&gt; 8</code> is the instrument count, <code>param0 &gt;&gt; 8</code> the sample
+bank id (5 for all three), and all have zero drums and zero SFX. 119 instruments =
+phoneme codes <code>0x01</code>–<code>0x77</code> exactly.</p>
+
+<p>Bank 157 stopping at 83 is not an omission: instruments 0–82 cover codes
+<code>0x01</code>–<code>0x53</code>, and the phonology engine never produces a code above
+<code>0x52</code> apart from <code>0x77</code>. The missing range
+<code>0x53</code>–<code>0x76</code> is the <a href="editor.html">editor's letter-name block</a>,
+which spec 3 can never reach. The only thing spec-3 voices genuinely lack is
+<code>you</code>.</p>
+
+<p>Sample details are in <code>../extraction.md</code>: N64 VADPCM, tuning 0.6891
+(22050 Hz), order 2, two predictors, no loop, ~190 ms each.</p>
+
+<div class="pager">
+ <span><a href="phonology.html">← 4. The phonology engine</a></span>
+ <span><a href="soundfont.html">6. The soundfont lookup →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/quirks.html b/docs/quirks.html
new file mode 100644
index 0000000..bb860c9
--- /dev/null
+++ b/docs/quirks.html
@@ -0,0 +1,157 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>8. Quirks, dead code &amp; corrections — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 8. Quirks &amp; corrections</nav>
+
+<h1>8. Quirks, dead code &amp; corrections</h1>
+<p class="lede">Things a faithful port has to reproduce, things a clean reimplementation
+should drop, and where earlier notes were wrong.</p>
+
+<h2>Bugs that shipped</h2>
+
+<h3>Glyph scale → volume is a dead store</h3>
+<pre>if (scale &gt; 0x20) {
+ scale = scale - 0x20;
+ f31 = scale;
+ f31 = f31 * 0.15f;
+ f30 = sou_voice_se[0]._08 + f31; // computed…
+ f30 = 4.f; // …and immediately discarded
+ sou_voice_se[0]._08 = f30;
+} else {
+ scale = 0x1f - scale;
+ f31 = scale * 0.02f;
+ f30 = sou_voice_se[0]._08 - f31; // computed…
+ f30 = 0.4f; // …and immediately discarded
+ sou_voice_se[0]._08 = f30;
+}</pre>
+<p class="src"><code>game64.c_inc:2994</code></p>
+<p>The intent was clearly a smooth scale-to-volume ramp. What ships is a step function:
+any glyph larger than normal slams volume to <strong>4.0</strong> (a 6× jump over the
+0.65 baseline), anything smaller to <strong>0.4</strong>. Normal-size text passes
+<code>scale == 32</code> and skips the block entirely, so this is only audible on
+deliberately scaled dialogue.</p>
+<p>This is in the matching decomp, so retail does it. A faithful port needs it; a clean
+library does not.</p>
+
+<h3>The mode-2 dedup check is tautological</h3>
+<pre>case 2: {
+ if (sou_num_org == sou_num_org &amp;&amp; sou_num_org == sou_last_num2) i = 1;
+}</pre>
+<p class="src"><code>game64.c_inc:2711</code></p>
+<p>The first comparison is always true. The intent was presumably a three-in-a-row check
+mirroring mode 0; the effect is a two-in-a-row check. Deferred syllables therefore
+trigger the random substitution more often than intended.</p>
+
+<h3>SHIIN2BOIN is dead, and would read out of bounds</h3>
+<pre>const u8 SHIIN2BOIN[] = { 0, 1, 2, 3, 4, 5, 1, 2, … }; // 86 entries
+…
+sou_now_boin = SHIIN2BOIN[a]; // a can be up to 0x77 = 119</pre>
+<p class="src"><code>game64.c_inc:236</code>, written at <code>:2863</code></p>
+<p><code>sou_now_boin</code> is written here and initialised in <code>Sou_InitAudio</code>, and
+<strong>never read anywhere in the tree</strong>. Same for <code>sou_nobasu_count</code>
+("elongation count"), which is only ever set to 0. Both are vestigial mouth-shape /
+vowel tracking, presumably live in the N64 <em>Dōbutsu no Mori</em> code this was ported
+from. Because the table is 86 entries and <code>a</code> can reach 119, the write reads
+past the array — harmless only because the result is discarded.</p>
+<p>The table also has an obvious data oddity: entry 45 is the value <code>45</code>, where
+every neighbour is 0–5.</p>
+
+<h2>Design choices that surprise</h2>
+
+<ul>
+ <li><strong><code>n</code> is a vowel.</strong> <code>Sou_BoinShiinCheck</code> groups it with
+ <code>a e i o u</code>. This changes when silent&nbsp;e and magic&nbsp;e fire, so
+ <em>done</em>, <em>gone</em> and <em>none</em> get the silent-<em>e</em> drop.</li>
+
+ <li><strong><code>h</code> is silent.</strong> <code>Sou_TanboinHenkan('h')</code> returns
+ <code>0x00</code>, and code 0 has no instrument (guarded by
+ <code>if (a != 0 &amp;&amp; a &lt;= 0x77)</code>). Standalone <code>h</code> makes no sound;
+ it only speaks through <code>ch sh th ha he hi ho oh</code>.</li>
+
+ <li><strong>A space can never open a window.</strong> <code>mMsg_sound_voice_entry</code>
+ refuses <code>voice_no == 0x81</code> in the "first character" branch, so word-initial
+ pairing always starts on a letter.</li>
+
+ <li><strong>Animalese is never panned or attenuated.</strong> <code>pan</code> stays at
+ <code>0x3F</code> and <code>reverbVolume</code> at 0 for the life of the program. A
+ villager at the far edge of the screen is exactly as loud and as centred as one in
+ your face — the only positional SE in the game that ignores position.</li>
+
+ <li><strong>The two subtracks share one parameter set.</strong> <code>sou_voice_se[0]</code>
+ and <code>[1]</code> are always written together, and <code>Sou_VoiceMake</code> pushes
+ both every frame — so a new syllable retroactively changes the volume and pitch
+ of the one still ringing.</li>
+
+ <li><strong>Voice mode is not the config setting.</strong> Animalese only ever plays for
+ messages that pass <code>mMsg_check_sound_animal</code>; and the submenu forces
+ <code>sAdo_SetVoiceMode(0)</code> regardless of config, so letter-writing always
+ speaks even for players who set the game to silent.</li>
+
+ <li><strong>Sequence-load races drop characters.</strong> After a spec change,
+ <code>Na_VoiceSe</code> returns early until
+ <code>Nap_ReadSubPort(VOICE_GROUP, 0, 1) == sou_now_voice_seq</code>. The first
+ syllables of the first line with a new speaker can be silently lost.</li>
+</ul>
+
+<h2>Corrections to earlier notes</h2>
+
+<p>The following supersede statements in <code>../extraction.md</code> as originally written.</p>
+
+<table>
+<tr><th>Was</th><th>Actually</th></tr>
+
+<tr><td>"Bank 157 has only 83 instruments … that voice collapses every digraph sample
+above <code>0x53</code> onto one sound."</td>
+<td>Nothing above <code>0x53</code> is a digraph. The phonology engine's maximum output is
+<code>0x52</code>, plus <code>0x77</code>. Bank 157 covers the phonology range exactly; the
+missing block is the <a href="editor.html">editor's letter names</a>, unreachable at spec 3.
+The only real loss is <code>you</code>.</td></tr>
+
+<tr><td>"43 of the 119 codes are unreachable through normal text … only ever hit by the
+anti-repetition random substitution."</td>
+<td>36 of them (<code>0x53</code>–<code>0x76</code>) are reachable — through the mode-1
+editor path — and the random substitution can <em>not</em> reach them, since it only
+produces <code>0x00</code>–<code>0x3F</code>.</td></tr>
+
+<tr><td>"<code>Sou_SpecialRoutine10</code> replays it on the next <em>audio</em> frame."</td>
+<td>It runs inside <code>Na_GameFrame</code>, which <code>graph.c</code> calls once per
+<em>game</em> frame. Same ~17 ms at 60 fps, but the mechanism is the game loop, and it
+halves under <code>SetGameFrame(2)</code>.</td></tr>
+
+<tr><td>"♪ … calls <code>Na_MelodyVoice(npc_id)</code> — the villager's tune."</td>
+<td>It plays a fixed 16-note pattern <code>{13, 14 × 15}</code>, not a tune. The per-NPC
+part is <em>which instrument</em>: <code>npc_voice_id</code> selects a chunk of sequence
+248 to copy in. It is the speaker's timbre playing a constant figure.</td></tr>
+
+<tr><td>Specs 5 and 6 listed only in the spec→sequence table, unattributed.</td>
+<td>They are the <strong>player's own voice</strong> in text-entry menus — 5 male, 6
+female — set from <code>m_submenu.c:364</code>, and are special-cased throughout
+<code>Na_VoiceSe</code>.</td></tr>
+
+<tr><td>Voice mode not covered.</td>
+<td>Three modes gate the whole pipeline, and the gate depends on the message, not just
+the config. See <a href="gating.html">gating</a>.</td></tr>
+
+<tr><td>Mode <code>f == 1</code> not covered.</td>
+<td>A third calling convention that bypasses phonology entirely. See
+<a href="editor.html">the editor path</a>.</td></tr>
+</table>
+
+<p>Everything else in <code>extraction.md</code> §1 was re-verified against the current tree
+and holds: the voice table, the 55 digraph entries, both vowel tables, the per-spec
+timbre values, and the spec→sequence→bank mapping.</p>
+
+<div class="pager">
+ <span><a href="editor.html">← 7. The editor path</a></span>
+ <span><a href="reference.html">9. Reference tables →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/reference.html b/docs/reference.html
new file mode 100644
index 0000000..5b86ebb
--- /dev/null
+++ b/docs/reference.html
@@ -0,0 +1,217 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>9. Reference tables — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 9. Reference tables</nav>
+
+<h1>9. Reference tables</h1>
+<p class="lede">Everything needed to reimplement the pipeline, plus an index into the
+decomp.</p>
+
+<h2>Voice codes</h2>
+
+<table>
+<tr><th>Code</th><th>Char</th><th>Class</th></tr>
+<tr><td><code>0x53</code>–<code>0x5C</code></td><td><code>0</code>–<code>9</code></td><td>2 (neither)</td></tr>
+<tr><td><code>0x5D</code> <code>0x61</code> <code>0x65</code> <code>0x6A</code> <code>0x6B</code> <code>0x71</code></td><td><code>a e i n o u</code></td><td>0 (vowel)</td></tr>
+<tr><td>other of <code>0x5D</code>–<code>0x76</code></td><td><code>b c d f g h j k l m p q r s t v w x y z</code></td><td>1 (consonant)</td></tr>
+<tr><td><code>0x80</code>–<code>0x86</code>, <code>0xFF</code></td><td>♪ &nbsp;␣ &nbsp;? &nbsp;! &nbsp;⏎ &nbsp;mute &nbsp;click &nbsp;empty</td><td>2 (neither)</td></tr>
+</table>
+
+<p>Letter <em>L</em> ↔ code: <code>0x5D + (L − 'a')</code>. Digit <em>D</em> ↔ code:
+<code>0x53 + D</code>.</p>
+
+<h2>Phoneme codes</h2>
+
+<p>Phoneme code <em>v</em> plays soundfont instrument <em>v</em>&nbsp;−&nbsp;1. Code 0 is
+silence. Codes marked † are produced by more than one rule and therefore share a
+sample.</p>
+
+<h3>Short vowels (<code>Sou_TanboinHenkan</code>)</h3>
+<table>
+<tr><th><code>a</code></th><th><code>i</code></th><th><code>u</code></th><th><code>e</code></th><th><code>o</code></th><th><code>n</code></th></tr>
+<tr><td>0x01</td><td>0x14</td><td>0x29</td><td>0x0A</td><td>0x1D</td><td>0x1B</td></tr>
+</table>
+
+<h3>Long vowels (<code>Sou_ChouboinHenkan</code>)</h3>
+<table>
+<tr><th><code>a</code></th><th><code>i</code></th><th><code>u</code></th><th><code>e</code></th><th><code>o</code></th><th><code>n</code></th></tr>
+<tr><td>0x02 †</td><td>0x15 †</td><td>0x2A †</td><td>0x0E †</td><td>0x1E</td><td>0x1B</td></tr>
+</table>
+<p>Used only when <code>class(a) == vowel &amp;&amp; class(b) == consonant &amp;&amp; c == 'e'</code>.
+The tables are otherwise byte-identical.</p>
+
+<h3>Consonants (both tables)</h3>
+<table>
+<tr><th>Letter</th><th>Code</th><th>Notes</th></tr>
+<tr><td><code>b</code></td><td>0x07</td><td></td></tr>
+<tr><td><code>c</code></td><td>0x23 / 0x18</td><td>soft (= <code>s</code>) if next letter is <code>i</code> or <code>e</code>, else hard (= <code>k</code>)</td></tr>
+<tr><td><code>d</code></td><td>0x09</td><td></td></tr>
+<tr><td><code>f</code></td><td>0x11</td><td></td></tr>
+<tr><td><code>g</code></td><td>0x12</td><td></td></tr>
+<tr><td><code>h</code></td><td>0x00</td><td><strong>silent</strong> — no instrument</td></tr>
+<tr><td><code>j</code></td><td>0x17</td><td>shares <code>ge</code></td></tr>
+<tr><td><code>k</code> <code>q</code></td><td>0x18</td><td></td></tr>
+<tr><td><code>l</code></td><td>0x19</td><td></td></tr>
+<tr><td><code>m</code></td><td>0x1A</td><td></td></tr>
+<tr><td><code>p</code></td><td>0x21</td><td></td></tr>
+<tr><td><code>r</code></td><td>0x22</td><td></td></tr>
+<tr><td><code>s</code></td><td>0x23</td><td></td></tr>
+<tr><td><code>t</code></td><td>0x26</td><td>shares digit <code>2</code></td></tr>
+<tr><td><code>v</code></td><td>0x2D</td><td></td></tr>
+<tr><td><code>w</code></td><td>0x2E</td><td></td></tr>
+<tr><td><code>x</code></td><td>0x31</td><td>shares <code>ks</code></td></tr>
+<tr><td><code>y</code></td><td>0x15</td><td>shares long <code>i</code> and <code>ea</code></td></tr>
+<tr><td><code>z</code></td><td>0x30</td><td></td></tr>
+</table>
+
+<h3>Digits (both tables)</h3>
+<table>
+<tr><th><code>0</code></th><th><code>1</code></th><th><code>2</code></th><th><code>3</code></th><th><code>4</code></th><th><code>5</code></th><th><code>6</code></th><th><code>7</code></th><th><code>8</code></th><th><code>9</code></th></tr>
+<tr><td>0x32</td><td>0x33</td><td>0x26</td><td>0x27</td><td>0x34</td><td>0x35</td><td>0x36</td><td>0x37</td><td>0x10</td><td>0x38</td></tr>
+</table>
+
+<h3>Digraphs (<code>Sou_ConnectCheck</code>) — 53 entries</h3>
+<table>
+<tr><th>Pair</th><th>Code</th><th>Pair</th><th>Code</th><th>Pair</th><th>Code</th><th>Pair</th><th>Code</th></tr>
+<tr><td><code>ai</code></td><td>0x0F</td><td><code>go</code></td><td>0x3E</td><td><code>no</code></td><td>0x49</td><td><code>si</code></td><td>0x36</td></tr>
+<tr><td><code>al</code></td><td>0x02</td><td><code>ha</code></td><td>0x3F</td><td><code>of</code></td><td>0x4A</td><td><code>so</code></td><td>0x4F</td></tr>
+<tr><td><code>an</code></td><td>0x39</td><td><code>he</code></td><td>0x40</td><td><code>oh</code></td><td>0x4B</td><td><code>su</code></td><td>0x25</td></tr>
+<tr><td><code>ar</code></td><td>0x16</td><td><code>hi</code></td><td>0x41</td><td><code>ok</code></td><td>0x4C</td><td><code>th</code></td><td>0x28</td></tr>
+<tr><td><code>as</code></td><td>0x3A</td><td><code>ho</code></td><td>0x42</td><td><code>on</code></td><td>0x4D</td><td><code>to</code></td><td>0x50</td></tr>
+<tr><td><code>be</code></td><td>0x3B</td><td><code>i␣</code></td><td>0x05</td><td><code>oo</code></td><td>0x2A</td><td><code>up</code></td><td>0x51</td></tr>
+<tr><td><code>by</code></td><td>0x3C</td><td><code>if</code></td><td>0x43</td><td><code>or</code></td><td>0x2C</td><td><code>we</code></td><td>0x52</td></tr>
+<tr><td><code>ch</code></td><td>0x08</td><td><code>in</code></td><td>0x44</td><td><code>ou</code></td><td>0x20</td><td><code>wo</code></td><td>0x33</td></tr>
+<tr><td><code>do</code></td><td>0x3D</td><td><code>ir</code></td><td>0x0C</td><td><code>ow</code></td><td>0x06</td><td><code>you</code></td><td>0x77</td></tr>
+<tr><td><code>ea</code></td><td>0x15</td><td><code>is</code></td><td>0x45</td><td><code>oy</code></td><td>0x1F</td><td><code>ze</code></td><td>0x32</td></tr>
+<tr><td><code>er</code></td><td>0x0D</td><td><code>it</code></td><td>0x46</td><td><code>re</code></td><td>0x0E</td><td></td><td></td></tr>
+<tr><td><code>fa</code></td><td>0x35</td><td><code>ks</code></td><td>0x31</td><td><code>se</code></td><td>0x37</td><td></td><td></td></tr>
+<tr><td><code>fo</code></td><td>0x34</td><td><code>me</code></td><td>0x47</td><td><code>sh</code></td><td>0x24</td><td></td><td></td></tr>
+<tr><td><code>ge</code></td><td>0x17</td><td><code>my</code></td><td>0x48</td><td><code>na</code></td><td>0x38</td><td></td><td></td></tr>
+<tr><td></td><td></td><td><code>ng</code></td><td>0x1C</td><td></td><td></td><td></td><td></td></tr>
+</table>
+<p>Of the 119 codes <code>0x01</code>–<code>0x77</code>, the phonology engine produces 76.
+The 43 it never produces are seven isolated gaps —
+<code>0x03 0x04 0x0B 0x13 0x2B 0x2F 0x4E</code> — plus the 36-code block
+<code>0x53</code>–<code>0x76</code>. The anti-repetition substitution can reach the first six
+of the gaps (it only emits <code>0x00</code>–<code>0x3F</code>); the block is reachable only
+via <a href="editor.html">the editor path</a>; <code>0x4E</code> appears to be reachable by
+nothing at all.</p>
+
+<h2>Specs</h2>
+
+<table>
+<tr><th>Spec</th><th>Seq</th><th>Bank</th><th>Vol</th><th>Pitch</th><th>Speaker</th></tr>
+<tr><td>2</td><td>243</td><td>156</td><td>0.65</td><td>1.00</td><td>lazy &amp; jock villagers; most special NPCs (default)</td></tr>
+<tr><td>3</td><td>244</td><td>157</td><td>0.65</td><td>1.00</td><td>cranky villagers; Wendell, Pete, Booker, Jack</td></tr>
+<tr><td>4</td><td>245</td><td>158</td><td>0.65</td><td>1.00</td><td>normal, peppy, snooty villagers; Pelly, Phyllis, Mabel, Sable, Blanca; event narration</td></tr>
+<tr><td>5</td><td>243</td><td>156</td><td>0.90</td><td>1.00</td><td>player, male — text editor only</td></tr>
+<tr><td>6</td><td>245</td><td>158</td><td>0.90</td><td>1.00</td><td>player, female — text editor only</td></tr>
+<tr><td>7</td><td>243</td><td>156</td><td>0.90</td><td>1.30</td><td>Timmy &amp; Tommy (via control code 93)</td></tr>
+<tr><td>8</td><td>245</td><td>158</td><td>0.90</td><td>0.75</td><td>Joan</td></tr>
+<tr><td>9</td><td>243</td><td>156</td><td>0.70</td><td>0.65</td><td>Farley</td></tr>
+</table>
+
+<h2>Text control codes that affect voice</h2>
+
+<table>
+<tr><th>Value</th><th>Name</th><th>Effect</th></tr>
+<tr><td>3</td><td><code>CURSOR_SET_TIME</code></td><td>pauses the cursor; lets a half-built window fire early</td></tr>
+<tr><td>75–79, 97</td><td><code>SET_MESSAGE_CONTENTS_*</code></td><td>emotion state (normal / angry / sad / fun / sleepy / gloomy)</td></tr>
+<tr><td>81</td><td><code>SOUND_CUT</code></td><td>plays system SE <code>0x54</code>, sends the end code, suppresses voice</td></tr>
+<tr><td>84</td><td><code>SET_CHAR_SCALE</code></td><td>changes glyph scale → the <a href="quirks.html">volume step</a></td></tr>
+<tr><td>90</td><td><code>SET_LINE_SCALE</code></td><td>same, per line</td></tr>
+<tr><td>92</td><td><code>VOICE_TRUE</code></td><td>restore spec (Timmy/Tommy only)</td></tr>
+<tr><td>93</td><td><code>VOICE_FALSE</code></td><td>spec 7 (Timmy/Tommy only)</td></tr>
+</table>
+
+<h2>Source index</h2>
+
+<p>Paths relative to the ac-decomp root. Line numbers are for commit
+<code>09ca8e8b</code>.</p>
+
+<h3>Message layer</h3>
+<table>
+<tr><td><code>src/game/m_msg_sound.c_inc:1</code></td><td><code>mMsg_sound_voice_get</code> — the 256-entry table</td></tr>
+<tr><td><code>&#8203;:26</code></td><td><code>mMsg_sound_voice_get_for_editor</code></td></tr>
+<tr><td><code>&#8203;:44</code></td><td><code>mMsg_sound_voice_get2</code> — newline injection</td></tr>
+<tr><td><code>&#8203;:52</code></td><td><code>mMsg_check_sound_special</code> — forced-voice message ranges</td></tr>
+<tr><td><code>&#8203;:66</code></td><td><code>mMsg_check_sound_animal</code></td></tr>
+<tr><td><code>&#8203;:85</code></td><td><code>mMsg_sound_voice_entry</code> — the (a, b, c) window</td></tr>
+<tr><td><code>&#8203;:117</code></td><td><code>mMsg_sound_voice_endcode_set</code></td></tr>
+<tr><td><code>&#8203;:126</code></td><td><code>mMsg_sound_CodeVoice</code></td></tr>
+<tr><td><code>&#8203;:259</code></td><td><code>mMsg_sound_voice_mode_get</code></td></tr>
+<tr><td><code>&#8203;:288</code></td><td><code>mMsg_sound_spec_change_voice_force</code></td></tr>
+<tr><td><code>&#8203;:317</code></td><td><code>mMsg_sound_spec_change_true/false</code> — Timmy/Tommy</td></tr>
+<tr><td><code>src/game/m_msg_cursol.c_inc:1100</code></td><td><code>mMsg_Main_Cursol_ControlCursol</code> — the cursor loop</td></tr>
+<tr><td><code>&#8203;:1184</code></td><td>the <code>NOT_PAUSE_FRAME</code> toggle</td></tr>
+<tr><td><code>src/game/m_msg_draw_font.c_inc:35</code></td><td>voice-fire decision</td></tr>
+<tr><td><code>src/game/m_font_main.c_inc:951</code></td><td><code>mFontSentence_animal_voice_se</code> — scale, and the call</td></tr>
+<tr><td><code>&#8203;:1078</code></td><td>the draw-loop trigger</td></tr>
+<tr><td><code>src/game/m_npc.c:32</code></td><td><code>l_sp_actor_name</code> — special-NPC spec table</td></tr>
+<tr><td><code>&#8203;:4116</code></td><td><code>mNpc_GetNpcSoundSpec</code></td></tr>
+<tr><td><code>src/game/m_submenu.c:364</code></td><td>player voice, specs 5 / 6</td></tr>
+<tr><td><code>src/game/m_editor_ovl.c:712</code></td><td>the mode-1 editor path</td></tr>
+</table>
+
+<h3>Bridge</h3>
+<table>
+<tr><td><code>src/audio.c:96</code></td><td><code>sAdo_VoiceSe</code></td></tr>
+<tr><td><code>&#8203;:152</code></td><td><code>sAdo_SetVoiceMode</code></td></tr>
+<tr><td><code>&#8203;:216</code></td><td><code>sAdo_SpecChange</code></td></tr>
+<tr><td><code>include/audio.h:45</code></td><td><code>VOICE_STATUS_*</code></td></tr>
+<tr><td><code>include/audio_defs.h:10</code></td><td><code>VOICE_MODE_*</code></td></tr>
+</table>
+
+<h3>Audio engine — <code>src/static/jaudio_NES/game/game64.c_inc</code></h3>
+<table>
+<tr><td><code>:22</code></td><td><code>VOICE_GROUP</code> = <code>NA_GROUP4</code></td></tr>
+<tr><td><code>:97</code></td><td><code>SOU_VOICE_SE</code> struct</td></tr>
+<tr><td><code>:236</code></td><td><code>SHIIN2BOIN</code> (dead)</td></tr>
+<tr><td><code>:311</code></td><td><code>sou_voice_se_init</code> — pan <code>0x3F</code>, reverb 0</td></tr>
+<tr><td><code>:820</code></td><td><code>Sou_VoiceStart</code></td></tr>
+<tr><td><code>:1174</code></td><td><code>Sou_VoiceMake</code></td></tr>
+<tr><td><code>:1708</code></td><td><code>Sou_SpecialRoutine10</code> — deferred syllable</td></tr>
+<tr><td><code>:1852</code></td><td><code>Na_GameFrame</code></td></tr>
+<tr><td><code>:2146</code></td><td><code>Sou_TanboinHenkan</code></td></tr>
+<tr><td><code>:2231</code></td><td><code>Sou_ChouboinHenkan</code></td></tr>
+<tr><td><code>:2316</code></td><td><code>Sou_ConnectCheck</code></td></tr>
+<tr><td><code>:2564</code></td><td><code>Sou_BoinShiinCheck</code></td></tr>
+<tr><td><code>:2598</code></td><td><code>Na_VoiceSe</code></td></tr>
+<tr><td><code>:3319</code></td><td><code>Na_SetVoiceMode</code></td></tr>
+<tr><td><code>:3459</code></td><td><code>Na_SpecChange</code></td></tr>
+<tr><td><code>melody.c:727</code></td><td><code>Na_MelodyVoice</code> — the ♪ handler</td></tr>
+<tr><td><code>audioheaders.c:4073</code></td><td>bank 156–158 entries</td></tr>
+<tr><td><code>audioheaders.c:4176</code></td><td><code>AudiodataHeaderStart</code> — the three archives</td></tr>
+<tr><td><code>audioheaders.c:4717</code></td><td>seq 243–245 → bank 156–158</td></tr>
+</table>
+
+<h3>Soundfont / bank loading</h3>
+<table>
+<tr><td><code>include/jaudio_NES/audiostruct.h:74</code></td><td><code>ArcEntry</code> / <code>ArcHeader</code></td></tr>
+<tr><td><code>&#8203;:95</code></td><td><code>adpcmloop</code>, <code>adpcmbook</code>, <code>smzwavetable</code>, <code>wtstr</code></td></tr>
+<tr><td><code>&#8203;:348</code></td><td><code>voicetable</code> — the instrument</td></tr>
+<tr><td><code>&#8203;:373</code></td><td><code>voiceinfo</code> — per-bank runtime info</td></tr>
+<tr><td><code>src/static/jaudio_NES/internal/system.c:772</code></td><td><code>__Link_BankNum</code> — alias entries</td></tr>
+<tr><td><code>&#8203;:813</code></td><td><code>Nas_BankOfsToAddr_Inner</code> — offset → pointer</td></tr>
+<tr><td><code>&#8203;:1171</code></td><td><code>__SetVlute</code> — <code>param0/1/2</code> decode</td></tr>
+<tr><td><code>&#8203;:1692</code></td><td><code>__WaveTouch</code> — sample vs bank relocation</td></tr>
+<tr><td><code>internal/channel.c:354</code></td><td><code>NoteToVoice</code></td></tr>
+<tr><td><code>&#8203;:368</code></td><td><code>ProgToVp</code> — instrument index → <code>voicetable</code></td></tr>
+<tr><td><code>internal/track.c:1159</code></td><td><code>Nas_ProgramChanger</code></td></tr>
+<tr><td><code>&#8203;:1399</code></td><td><code>SUBTRACK_CMD_SET_INSTRUMENT_BANK</code> — reverse bank-list index</td></tr>
+</table>
+
+<div class="pager">
+ <span><a href="quirks.html">← 8. Quirks &amp; corrections</a></span>
+ <span><a href="index.html">Index</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/scheduling.html b/docs/scheduling.html
new file mode 100644
index 0000000..581dff6
--- /dev/null
+++ b/docs/scheduling.html
@@ -0,0 +1,147 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>3. Windowing &amp; timing — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 3. Windowing &amp; timing</nav>
+
+<h1>3. Windowing &amp; timing</h1>
+<p class="lede">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.</p>
+
+<h2>The text cursor</h2>
+
+<p><code>mMsg_Main_Cursol_ControlCursol</code> (<code>src/game/m_msg_cursol.c_inc:1100</code>)
+runs once per game frame while a message is scrolling. Its loop:</p>
+
+<ol>
+ <li>Decrement <code>cursor_timer</code>; if still positive, do nothing this frame.
+ (Control code <code>mFont_CONT_CODE_CURSOR_SET_TIME</code>, value 3, sets it — this is
+ how messages pause mid-sentence.)</li>
+ <li>Skip newlines, message tags and control codes, executing their side effects.</li>
+ <li>On a printable character: advance <code>end_text_cursor_idx</code>
+ <strong>only if <code>mMsg_STATUS_FLAG_NOT_PAUSE_FRAME</code> is clear</strong>,
+ then toggle that flag, then call <code>mMsg_sound_CodeVoice(msg_p, cur_idx)</code>
+ and break.</li>
+</ol>
+
+<p>The flag toggle is the speed control: the index moves on alternate frames, so text
+advances at <strong>one character per two frames</strong>, roughly 33 characters/second
+at 60 fps.</p>
+
+<p>Holding <kbd>B</kbd> sets <code>mMsg_STATUS_FLAG_FAST_TEXT</code>, which zeroes
+<code>cursor_timer</code> and <em>clears</em> <code>NOT_PAUSE_FRAME</code> each frame, giving one
+character per frame. It also switches the reported message speed from 2 to 1
+(<code>mMsg_sound_MessageSpeedClear</code>), which the audio engine stores in
+<code>sou_message_speed</code>.</p>
+
+<h2>Building the (a, b, c) window</h2>
+
+<p><code>mMsg_sound_CodeVoice</code> (<code>m_msg_sound.c_inc:126</code>) converts the character
+and hands it to <code>mMsg_sound_voice_entry</code> (<code>:85</code>), which is the state
+machine:</p>
+
+<pre>if (idx == msg_p-&gt;voice_sfx_idx) return; // already seen this index
+if (idx == msg_p-&gt;end_text_cursor_idx) return; // this is a non-advancing frame
+msg_p-&gt;voice_sfx_idx = idx;
+
+if (status_flags &amp; 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;
+}</pre>
+
+<p>Two guards deserve attention. The <code>end_text_cursor_idx</code> 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 (<code>0x81</code>) is never allowed to be the <code>a</code> of a
+window, only the <code>b</code>; this is why utterances tend to break <em>after</em> a
+space rather than starting on one.</p>
+
+<p>The <code>c</code> lookahead reads one character past <code>b</code> and clamps a newline or a
+control code to <code>0xFF</code>. It exists for exactly two rules in the phonology:
+<code>you</code> and magic&nbsp;e.</p>
+
+<h2>Firing</h2>
+
+<p>Nothing plays from the message code. The trigger is in the draw pass —
+<code>mMsg_draw_font</code> (<code>src/game/m_msg_draw_font.c_inc:35</code>) decides whether
+this frame's glyph should speak:</p>
+
+<pre>if (status_flags &amp; VOICE_ENTRY) {
+ if (status_flags &amp; FLAG_18) voice = TRUE; // pair complete
+ else if (cursor_timer != 0 || force_voice_enable_flag) voice = TRUE;
+}</pre>
+
+<p>It then finds the line containing <code>voice_sfx_idx</code>, sets
+<code>mFont_SENTENCE_FLAG_VOICE_SE</code> on that line's <code>mFontSentence</code>, and clears
+both flags. <code>mFontSentence_gppDraw_main</code>
+(<code>src/game/m_font_main.c_inc:1078</code>) calls the speak function immediately after
+drawing the glyph at index <code>se_pos</code>, so audio is frame-locked to the character
+appearing on screen.</p>
+
+<p>The second branch is the escape hatch: if the cursor is paused (a
+<code>CURSOR_SET_TIME</code> code) or a control code returned
+<code>mMsg_RESULT_TRUE</code>, a half-built window with <code>b = c = 0xFF</code> fires anyway
+rather than being held indefinitely.</p>
+
+<h3>Scale → volume</h3>
+
+<p><code>mFontSentence_animal_voice_se</code> (<code>m_font_main.c_inc:951</code>) computes the
+<code>scale</code> argument from the glyph's own transform:</p>
+
+<pre>f_scale = sqrtf(scale.x² + scale.y²);
+scale = line_scale * f_scale * (32.0f / √2) + 0.5f; // clamped 0…255</pre>
+
+<p>32 is normal size, so a normally scaled glyph passes <code>scale == 32</code> and the
+volume adjustment is skipped entirely. (What happens for other values is
+<a href="quirks.html">not what it looks like</a>.)</p>
+
+<p>It also contains one very specific special case: if the character is ♪ at exactly
+normal scale in colour <code>rgba(50, 60, 50, 255)</code>, the NPC id is forced to 0,
+suppressing the melody. This is presumably a specific piece of dialogue where a
+literal music note glyph should stay silent.</p>
+
+<h2>End of message</h2>
+
+<p><code>mMsg_sound_voice_endcode_set</code> (<code>m_msg_sound.c_inc:117</code>) sends
+<code>sAdo_VoiceSe(0x84, 0xFF, 0xFF, npc, 32, 0)</code> — a synthetic newline — and clears
+both window flags. It runs when the message ends, and also when the
+<code>mFont_CONT_CODE_SOUND_CUT</code> control code (value 81) appears, which additionally
+plays system SE <code>0x54</code> and suppresses the rest of the line's voice.</p>
+
+<h2>The resulting cadence</h2>
+
+<pre>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
+…</pre>
+
+<p>So: one <code>Na_VoiceSe</code> 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
+<a href="playback.html">two alternating subtracks</a>.</p>
+
+<div class="pager">
+ <span><a href="encoding.html">← 2. Text → voice code</a></span>
+ <span><a href="phonology.html">4. The phonology engine →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/soundfont.html b/docs/soundfont.html
new file mode 100644
index 0000000..c07caf9
--- /dev/null
+++ b/docs/soundfont.html
@@ -0,0 +1,226 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>6. The soundfont lookup — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 6. The soundfont lookup</nav>
+
+<h1>6. The soundfont lookup</h1>
+<p class="lede">How a phoneme code becomes bytes on disk. Sequence and bank ids are
+address lookups; instrument numbers are not — they resolve through offsets stored
+inside the bank blob itself.</p>
+
+<h2>Four tables, not one</h2>
+
+<p>Everything is driven by tables in <code>src/static/jaudio_NES/game/audioheaders.c</code>,
+which is compiled into the DOL. <strong>None of it lives in
+<code>audiorom.img</code></strong> — the image is pure payload with no table of contents,
+which is why extraction needs the decomp.</p>
+
+<table>
+<tr><th>Table</th><th>Entries</th><th>Indexes into</th></tr>
+<tr><td><code>AudiodataHeaderStart</code></td><td>3</td><td><code>audiorom.img</code> — where the three regions are</td></tr>
+<tr><td><code>AudioseqHeaderStart</code></td><td>249</td><td>the Audioseq region</td></tr>
+<tr><td><code>AudiobankHeaderStart</code></td><td>159</td><td>the Audiobank region</td></tr>
+<tr><td><code>AudiowaveHeaderStart</code></td><td>6</td><td>the Audiowave region</td></tr>
+</table>
+
+<p>All four are <code>ArcHeader</code>s over a common entry type
+(<code>include/jaudio_NES/audiostruct.h:74</code>):</p>
+
+<pre>typedef struct ArcEntry_ {
+ u32 addr; /* offset within this region */
+ s32 size;
+ s8 medium; /* MEDIUM_RAM 0, DISK 1, CART 2, DISK_DRIVE 3 */
+ s8 cacheType;
+ s16 param0, param1, param2;
+} ArcEntry; /* 0x10 */</pre>
+
+<p><code>addr</code> and <code>size</code> are the address spec. <code>param0</code>–<code>param2</code>
+are unused for sequences and waves. For <strong>banks</strong> they carry metadata the
+loader needs <em>before</em> it can parse the blob, since the blob has no header of its
+own — <code>__SetVlute</code> (<code>internal/system.c:1171</code>):</p>
+
+<pre>vinfo-&gt;wave_bank_id0 = (entry-&gt;param0 &gt;&gt; 8) &amp; 0xFF;
+vinfo-&gt;wave_bank_id1 = (entry-&gt;param0 &gt;&gt; 0) &amp; 0xFF;
+vinfo-&gt;num_instruments = (entry-&gt;param1 &gt;&gt; 8) &amp; 0xFF;
+vinfo-&gt;num_drums = (entry-&gt;param1 &gt;&gt; 0) &amp; 0xFF;
+vinfo-&gt;num_sfx = entry-&gt;param2;</pre>
+
+<p>So bank 156's <code>param0 = 0x05FF</code> means "my samples are in wave bank 5, there is
+no second wave bank (<code>0xFF</code>)", and <code>param1 = 0x7700</code> means "119
+instruments, 0 drums". <strong>The instrument count is a number in a table entry, not an
+address.</strong></p>
+
+<div class="note">
+<strong>Alias entries.</strong> <code>__Link_BankNum</code>
+(<code>internal/system.c:772</code>) reads
+<code>if (header-&gt;entries[id].size == 0) id = header-&gt;entries[id].addr;</code>
+— a zero-size entry is not empty, its <code>addr</code> field is reinterpreted as
+<em>another entry's id</em>. Any tool walking these tables has to follow the redirect or
+it will read garbage for the aliased ids.
+</div>
+
+<h2>Sequence → bank is a fifth lookup</h2>
+
+<p><code>AudiomapHeaderStart</code> (<code>audioheaders.c:4221</code>) is a <code>u8[]</code> that
+indexes itself:</p>
+
+<pre>u16 offset[seq_id] // absolute offset into this same array
+ at that offset: u8 count, then u8 bank_id[count]</pre>
+
+<p>The voice sequences sit at the tail: <code>1, 0x9C</code> / <code>1, 0x9D</code> /
+<code>1, 0x9E</code> — one bank each, 156 / 157 / 158.</p>
+
+<p>The sequence's own <code>set_bank</code> command does not carry a bank id. It carries an
+index into <em>its own</em> list, counted from the end
+(<code>internal/track.c:1399</code>):</p>
+
+<pre>cmdArgU16 = ((u16*)AG.map_header)[grp-&gt;seq_id];
+lo_bits = ((u8*)AG.map_header)[cmdArgU16]; // count
+// inverse lookup, 1 = last bank, 2 = second to last, …
+cmdArgU8 = ((u8*)AG.map_header)[cmdArgU16 + lo_bits - cmdArgU8];
+if (Nas_SzCacheCheck(BANK_TABLE, CACHE_EITHER, cmdArgU8))
+ subtrack-&gt;bank_id = cmdArgU8;</pre>
+
+<p>That indirection lets a sequence be re-pointed at a different bank by editing the map
+alone. For the voice sequences the list has one entry, so the byte is always 1.</p>
+
+<h2>Inside the bank blob</h2>
+
+<p>Pure offsets, all relative to the blob's own start:</p>
+
+<pre>u32 drumListOffset
+u32 sfxListOffset
+u32 instrumentOffset[num_instruments] ← count came from param1</pre>
+
+<p>Each instrument is a <code>voicetable</code>
+(<code>audiostruct.h:348</code>, 0x20 bytes):</p>
+
+<pre>typedef struct voicetable_ {
+ u8 is_relocated;
+ u8 normal_range_low;
+ u8 normal_range_high;
+ u8 adsr_decay_idx;
+ envdat* envelope; /* offset → pointer */
+ wtstr low_pitch_tuned_sample; /* { smzwavetable*, f32 tuning } */
+ wtstr normal_pitch_tuned_sample;
+ wtstr high_pitch_tuned_sample;
+} voicetable;</pre>
+
+<p>and each sample descriptor a <code>smzwavetable</code> (0x10):</p>
+
+<pre>typedef struct smzwavetable_ {
+ u32 bit31 : 1;
+ u32 codec : 3; /* 0 = CODEC_ADPCM (N64 VADPCM) */
+ u32 medium : 2;
+ u32 bit26 : 1;
+ u32 is_relocated : 1;
+ u32 size : 24;
+ u8* sample; /* offset into the WAVE bank */
+ adpcmloop* loop; /* offset within THIS bank */
+ adpcmbook* book; /* offset within THIS bank */
+} smzwavetable;</pre>
+
+<p>with <code>adpcmbook = { s32 order, s32 n_predictors, s16 codebook[] }</code> and
+<code>adpcmloop = { u32 loop_start, loop_end, count, sample_end, s16 state[16] if count &gt; 0 }</code>.</p>
+
+<h2>Relocation at load time</h2>
+
+<p><code>Nas_BankOfsToAddr_Inner</code> (<code>internal/system.c:813</code>) walks the blob once
+and rewrites every offset into a real pointer in place, guarded by
+<code>is_relocated</code> because an instrument can appear in the list more than once.
+Its per-sample helper <code>__WaveTouch</code> (<code>:1692</code>) contains the one asymmetry
+that matters:</p>
+
+<pre>wavetable-&gt;loop = (adpcmloop*)((u32)wavetable-&gt;loop + ram_addr); // + bank base
+wavetable-&gt;book = (adpcmbook*)((u32)wavetable-&gt;book + ram_addr); // + bank base
+
+switch (wavetable-&gt;medium) {
+ case MEDIUM_RAM: wavetable-&gt;sample += (u32)wave_media-&gt;wave0_p; break; // + WAVE base
+ case MEDIUM_DISK: wavetable-&gt;sample += (u32)wave_media-&gt;wave1_p; break;
+ case MEDIUM_CART: case MEDIUM_DISK_DRIVE: break; // already absolute
+}</pre>
+
+<p><strong>Loop points and the VADPCM codebook live in the bank; the PCM lives in a
+different region entirely.</strong> That is the whole reason <code>wave_bank_id0</code> exists —
+it names which of the six wave banks to add. <code>wave0_p</code> / <code>wave1_p</code>
+correspond to the two halves of <code>param0</code>.</p>
+
+<p>Two small quirks in the same function: the relocator clamps to
+<strong>126 instruments</strong> regardless of what <code>param1</code> claimed (no voice bank
+comes close), and it decides "not yet relocated" by testing
+<code>(u32)wavetable &lt;= OS_BASE_CACHED</code> — small values are offsets, large ones are
+already pointers.</p>
+
+<h2>The runtime chain, for one syllable</h2>
+
+<pre>Sou_VoiceStart(subtrack, 0x28) write phoneme 0x28 ("th") to port 0
+ ↓
+sequence 243 spins on the port, then set_dyntbl / dyntbl_call
+dyntbl[0x28] → routine "C1 27 / 88 &lt;note&gt; / FF"
+ ↓ C1 = set instrument 0x27 ← where code − 1 happens
+Nas_ProgramChanger(subtrack, 0x27, …) track.c:1159
+ ↓
+ProgToVp(subtrack-&gt;bank_id, 0x27) channel.c:368
+ ↓ if (inst &gt;= voice_info[prog].num_instruments) → error, NULL
+ voice_info[156].instruments[0x27] → voicetable*
+ ↓
+NoteToVoice(vtbl, note) channel.c:354
+ ↓ note &lt; normal_range_low → low sample
+ ↓ note &lt;= normal_range_high → normal sample
+ ↓ else → high sample
+wtstr { smzwavetable*, tuning } → sample, loop, book, codec, size</pre>
+
+<p><code>ProgToVp</code> is the only consumer of the instrument number, and it is a plain
+array index bounded by <code>num_instruments</code>. This is exactly why
+<code>you</code> (instrument 118) cannot work on bank 157's 83 instruments: the bounds
+check fails, <code>audio_error_flags</code> is set to
+<code>0x03000000 + (prog &lt;&lt; 8) + inst</code>, and nothing plays — though in practice the
+sequence's dynamic table has already clamped the index before it gets there.</p>
+
+<p>Every voice instrument uses only the <em>normal</em> slot, so <code>NoteToVoice</code> is a
+formality here.</p>
+
+<h2>Worked example — bank 156</h2>
+
+<pre>audiorom.img AudiodataHeaderStart
+├ Audioseq @ 0x000000 len 0x0CF700 entry 0
+├ Audiobank @ 0x0CF700 len 0x067C80 entry 1
+│ └ bank 156 @ +0x05C260 → 0x12B960 len 0x4500 AudiobankHeaderStart[156]
+│ ├ u32[0] drumList = 0 (num_drums = 0)
+│ ├ u32[1] sfxList = 0 (num_sfx = 0)
+│ ├ u32[2..120] 119 instrument offsets
+│ └ voicetables · envelopes · smzwavetables · loops · codebooks
+└ Audiowave @ 0x137380 len 0x6B33E0 entry 2
+ └ wave bank 5 @ +0x2C5DC0 → 0x3FD140 len 0x3ED620 AudiowaveHeaderStart[5]
+ └ raw VADPCM frames; smzwavetable.sample is an offset into here</pre>
+
+<p>All three voice banks share <code>param0 = 0x05FF</code>, so all animalese samples —
+119 + 83 + 119 instrument slots — point into that one 4 MB wave bank.</p>
+
+<div class="note">
+<strong>Field naming.</strong> <code>../extraction.md</code> §2.2 guessed
+<code>u8 loaded, rangeLo, rangeHi, releaseRate</code> for the instrument header. The decomp
+calls them <code>is_relocated</code>, <code>normal_range_low</code>,
+<code>normal_range_high</code>, <code>adsr_decay_idx</code>. Same layout, and
+<code>is_relocated</code> is a load-time scratch flag rather than anything meaningful in
+the file.
+</div>
+
+<p>Decoding the VADPCM frames themselves is covered in <code>../extraction.md</code> §2.3 —
+including the one easy mistake, which is convolving the decoded outputs instead of the
+scaled residuals.</p>
+
+<div class="pager">
+ <span><a href="playback.html">← 5. Playback</a></span>
+ <span><a href="editor.html">7. The editor path →</a></span>
+</div>
+
+</body>
+</html>
diff --git a/docs/style.css b/docs/style.css
new file mode 100644
index 0000000..78917cf
--- /dev/null
+++ b/docs/style.css
@@ -0,0 +1,83 @@
+/* minimal shared styling */
+:root {
+ --fg: #1a1a1a;
+ --dim: #666;
+ --rule: #d8d8d8;
+ --bg: #fdfdfc;
+ --code-bg: #f3f3f1;
+ --accent: #7a4a12;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --fg: #e4e2de;
+ --dim: #999;
+ --rule: #3a3a3a;
+ --bg: #17181a;
+ --code-bg: #202225;
+ --accent: #d8a76a;
+ }
+}
+
+html { -webkit-text-size-adjust: 100%; }
+body {
+ max-width: 46rem;
+ margin: 0 auto;
+ padding: 2.5rem 1.25rem 6rem;
+ background: var(--bg);
+ color: var(--fg);
+ font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+}
+
+h1, h2, h3 { line-height: 1.25; font-weight: 600; }
+h1 { font-size: 1.65rem; margin: 0 0 .25rem; }
+h2 { font-size: 1.2rem; margin: 2.5rem 0 .75rem; padding-top: .75rem; border-top: 1px solid var(--rule); }
+h3 { font-size: 1rem; margin: 1.75rem 0 .5rem; }
+
+a { color: var(--accent); }
+a:hover { text-decoration: none; }
+
+nav.crumb { font-size: .85rem; color: var(--dim); margin-bottom: 2rem; }
+nav.crumb a { color: var(--dim); }
+
+p.lede { color: var(--dim); margin: 0 0 2rem; }
+
+code, kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .875em; }
+:not(pre) > code { background: var(--code-bg); padding: .1em .35em; border-radius: 3px; }
+
+pre {
+ background: var(--code-bg);
+ padding: .85rem 1rem;
+ border-radius: 4px;
+ overflow-x: auto;
+ font-size: .8125rem;
+ line-height: 1.5;
+}
+
+table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .875rem; display: block; overflow-x: auto; }
+th, td { text-align: left; padding: .35rem .7rem .35rem 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
+th { font-weight: 600; white-space: nowrap; }
+td code { white-space: nowrap; }
+
+ul, ol { padding-left: 1.3rem; }
+li { margin: .3rem 0; }
+
+.src { font-size: .8125rem; color: var(--dim); }
+.src code { background: none; padding: 0; }
+
+.note {
+ border-left: 3px solid var(--accent);
+ padding: .1rem 0 .1rem .9rem;
+ margin: 1.25rem 0;
+ color: var(--dim);
+}
+.note strong { color: var(--fg); }
+
+.toc { list-style: none; padding: 0; }
+.toc li { margin: .9rem 0; }
+.toc a { font-weight: 600; }
+.toc span { display: block; color: var(--dim); font-size: .9rem; font-weight: 400; }
+
+.pager { margin-top: 3.5rem; padding-top: 1rem; border-top: 1px solid var(--rule); font-size: .9rem; display: flex; justify-content: space-between; gap: 1rem; }
+
+.grid2 { columns: 2; column-gap: 2rem; font-size: .875rem; }
+@media (max-width: 40rem) { .grid2 { columns: 1; } }