xmrs 0.13.2

A library to edit SoundTracker data with pleasure
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# Plan — lecture fidèle de **tout** `~/Music/dw` (`.dw`)

> Plan d'exécution multi-sessions, autoportant. À lire avec [`FORMAT.md`]FORMAT.md
> (spec + sémantique replayer + conventions). Rédigé 2026-05-29.
> **Objectif** : les 120 `.dw` chargent sans erreur **et** jouent avec une
> modélisation fidèle (oracle Ghidra/`.cs` si nécessaire), en un minimum de
> sessions, **sans question** à l'utilisateur sauf l'écoute finale.

---

## 0. État de départ (mesuré 2026-05-29)

- **120** fichiers dans `~/Music/dw/`. **116 chargent**, **4 rejetés** proprement
  en `dw_no_song` (moteurs SFX sans table PCM) : `anarchy sfx`, `blood money sfx`,
  `spellbound sfx`, `feud fake` (placeholder 2 Ko). ⇒ axe **chargement = atteint**
  (un module chargé n'a pas besoin de chant ; cf. FORMAT.md §10).
- **Fidélité = non vérifiée systématiquement.** Seuls xenon2 (title) — et au son,
  par l'utilisateur — ont servi d'oracle. Les approximations connues (FORMAT.md §8) :
  vibrato en espace demi-ton (≠ période), GlobalVolume/Fade cuit au row (pas de lane
  continue), enveloppe à résolution row, arpège tronqué à 3 offsets, `SetSpeed`
  mid-song non re-quantifié, half-volume / 3-canaux / square-waveform non couverts,
  `Effect9` restart non nul non modélisé.
- **Le slide est désormais exact** (rampe linéaire période, délai `counter`, apply
  chaque tick via quirk `pitch_slide_ticks_at_row_zero`, borné par `Clear`).

### Avancement (2026-05-29, Session A / Tier 1 — fait)
- **Tier 1 robustesse livré et vert** : `xmrsplayer/tests/dw_robustness.rs`
  (`render_all_dw_modules_robustness`). Rend les **116** modules via `XmrsPlayer`
  (song 0, `max_loop_count(1)`, cap 60 s @ 24 kHz), 0 panic. Invariant dur : tout
  canal portant une note **à vélocité > 0** doit rendre du son ⇒ **0 échec**.
  Avertissement doux (sans échec) : canal dont **toutes** les notes sont à
  vélocité-0 au row et qui rend muet ⇒ lacune volume-lane (backlog #2/#3).
- **Subtilité corrigée dans le harnais** : le player ne joue que **song 0** ; le
  recensement des canaux à notes doit être borné à song 0 (sinon faux positifs sur
  bmx/eye/back-to-the-future-2 dont les canaux « muets » ne portent des notes que
  dans d'autres sous-chants).
- **Seul résiduel signalé** : `emlyn hughes int soccer.dw` ch0 — 3024 notes,
  instrument #0 fort (max|127|, 120 touches mappées) mais **toutes** à vélocité 0
  (volume baké à 0 au row ; `fx=[Volume{value:0,tick:0}]`). ch2 (même profil
  vélocité-0) est relevé par sa lane et reste audible ; ch0 non. ⇒ cible directe de
  la modélisation **GlobalVolume/volume-lane continue** (Session B, backlog #2/#3).
- **Outil de triage ajouté** : `xmrs/examples/dw_channel_energy.rs` (par canal de
  song 0 : nb notes, nb vélocité>0, instruments + taille/`max|pcm|`/touches mappées,
  premières note-cells). A servi à disculper les 4 « échecs » initiaux.

### Avancement (2026-05-29, Session A / Tier 2 — oracle Ghidra fait & validé)
- **Oracle d'émulation 68k livré et reproductible** :
  `dw/oracle/DwPaulaOracle.java` (+ doc `dw/oracle/README.md`). Émule le replayer
  réel du `.dw` dans le p-code de Ghidra : `init_main` 1×, puis N× `play_tick`,
  avec un `MemoryAccessFilter` capturant **toutes** les écritures Paula
  (DMACON `0xdff096`, AUDxLC/LEN/PER/VOL `0xdff0a0+ch*0x10`). Reconstruit l'état
  Paula continu (gate DMA + période + volume clampé 0..64) snapshoté par tick.
  Sortie CSV (schéma dans le README). **Le script-fichier reproduit la trace
  inline à l'identique.**
- **Golden de référence** : `dw/oracle/xenon2_title.csv` (1500 ticks de
  `xenon2 (title).dw`, init=`0x66`/play=`0x1b6`).
- **Findings xenon2 validés** : intro qui monte les voix (DMA `0x201`@t1 →
  `0x203`@t78 → `0x20f`@t104) ; périodes gated saines (ch0 179–455 ≈ 7,8–19,8 kHz,
  ch1 157–671, ch2/3 179–506) ; bascules DMACON 1-tick = restarts de note ; vol
  écrit pouvant dépasser 64 (clamp Paula) ; `0xFC78`/`vol103` = scratch pré-gate
  (DMA off) correctement ignoré via le bit de gate.
- **Pré-requis env capturés** (ont coûté du temps au 1er run, voir README) :
  `GHIDRA_MCP_ALLOW_SCRIPTS=1` dans l'env **du process Ghidra** ; `~/ghidra_scripts`
  doit être un *source bundle* (sinon `GhidraPlaceholderBundle cast` → redémarrer
  Ghidra) ; SP 68k = `SP` (pas `A7`).
- **Reste pour clôturer Tier 2** : (a) observateur per-tick côté player
  (`xmrsplayer/src/audio_observer.rs`) exposant période/volume/gate par canal ;
  (b) alignement d'unités (player `Period` → période Amiga ; volume canal → 0..64 ;
  voix vivante → gate) ; (c) diff trame-à-trame contre les goldens → `BASELINE.md`.

### Avancement (2026-05-29, Session A / Tier 2 — les 9 goldens capturés)
- **Tous les représentants de cluster ont leur golden** dans `dw/oracle/*.csv` :
  C1 `xenon2_title`, C2 `bad_company`, C3 `bubble_bobble`, C4 `grimblood`,
  C5 `tetris`, C6 `qball`, C7 `leviathan_deabs`, C8 `the_empire_strikes_back`,
  + demi-volume `obliterator_title`. 1500 ticks chacun. Table des points d'entrée
  (init/play/A3/f0) + méthode de recherche dans `dw/oracle/README.md`.
- **Script généralisé & blindé** (`DwPaulaOracle.java`) : args
  `init play [ticks] [out] [a3] [f0] [maxSteps]`. `a3` pose une base A3 (bubble
  bobble = `0xfffffbbe`). `f0=-` désactive la détection de Stop (seul xenon2 a un
  vrai flag ; ailleurs ça tronquait à tort). `maxSteps>0` = **mode pas-à-pas
  borné** : la boucle est pilotée par le script, donc *ne peut plus figer Ghidra*
  (lève une erreur au-delà du plafond). À utiliser pour sonder toute entrée non
  validée (les vieux players C6/C7/C8 surtout).
- **Pièges capturés (ont coûté 2 gels de Ghidra à 100 % CPU)** : `emu.run()` ne
  consulte pas le `monitor` → un watchdog temps-réel ne sauve rien, seul le mode
  pas-à-pas borne le risque ; vieux players à prologue `movem` → l'entrée est
  l'adresse du `movem`, pas le label Ghidra (qball `0xd0``0xd4`) ; l'arg
  `program` de `run_ghidra_script` est ignoré → `switch_program` juste avant et
  vérifier la ligne `Program:`. Tout est dans le README + la mémoire.
- **Reste (inchangé)** : observateur per-tick côté player + diff trame-à-trame
  contre ces 9 goldens → `BASELINE.md` (une section par cluster).
  Les adresses init/play sont **spécifiques au binaire** : pour les autres clusters,
  les retrouver via la boucle 4-canaux écrivant `0xdff0a0+ch*0x10` (cf. README).

### Avancement (2026-05-29, Session A / Tier 2 — CLÔTURÉ, premier BASELINE)
- **Trace player** : `xmrsplayer/examples/dw_oracle_trace.rs` rend chaque `.dw`
  un tick replayer à la fois (`XmrsPlayer::advance_trace_tick`, sans rendu audio)
  et sort le **même schéma CSV** que les goldens. API lib ajoutée = *read-only,
  minimale* : `Channel::snapshot()`/`ChannelSnapshot{period(=AUDxPER), volume(0..64),
  gate(=voix vivante)}`, `Voices::channel_snapshots()`, `XmrsPlayer::channel_snapshots()`.
- **Généralisé en API observateur** (choix utilisateur) : `RowContext`/`TickContext`
  portent désormais `channels: &[ChannelSnapshot]` — donnée runtime utile aux
  scopes/VU/indicateurs d'activité, pas seulement au diff DW. Exporté au prelude.
  Tous les tests passent (31+8+1+doctests).
- **Diff** : `oracle/diff_baseline.py --markdown` aligne tick-à-tick et calcule
  par canal : accord de gate ; sur ticks doublement gated, période/volume
  (exact %, **médiane Δ signée** = offset systématique, MAE = dérive, max).
- **Résultats** (`BASELINE.md`, scoreboard vivant) :
  - **Gate 82–100 % partout** → la structure (quand un canal sonne) est juste.
  - **Période quasi-alignée** (medΔ ±2..±7) : C1, C4, C5 (medΔ +0 !), C6, obliterator
    — résidu = arrondi de table + **dérive vibrato/slide** (MAE 16–65).
  - **Mismatch period-space** (medΔ en centaines) : C2, C3, C7, C8 — mapping de
    période différent (≠ vieux/nouveau : C6 vieux est quasi-exact). C3 bubble bobble
    le pire (gate ch0 50 %, dispatch `jmp (0,A3,A2)` à revoir).
  - **Volume** : offsets *constants* corrigeables (tetris exactement +8, empire 40/15) ;
    déjà 100 % exact sur qball ch0/ch3, obliterator ch3.
- **Suite** = backlog de modélisation §6 (vibrato period-space, GlobalVolume,
  envelope frame-res, SetSpeed, demi-volume). Re-lancer le diff après chaque
  changement, mettre à jour la table de `BASELINE.md`.

### Avancement (2026-05-29, Session B / modélisation volume — 3 gains)
Backlog §6 attaqué côté volume, chaque correctif trouvé par **décomp Ghidra du
`PlayTick`** du cluster (le `.cs` est incomplet pour tous) puis vérifié au
diff. Détail complet + changelog dans `BASELINE.md`.
- **C5 tetris volume → 100 % exact** (4 canaux). `AUDxVOL = sampleInfo[+0xC] −
  DAT_41a`. L'importeur figeait `DwSample.volume=64` ; lit désormais le mot
  volume par échantillon à l'octet +12 (gate `enable_sample_transpose`,
  stride 16). Le 56/56/56/64 par canal = ces mots d'échantillon.
- **C6 qball volume → 99–100 % exact** (était 0 % sur ch1/ch2). Les vieux
  players chargent une **table de volume par canal** (`channelVolumes[]`) et
  l'écrivent BRUTE dans `AUDxVOL` à chaque note. qball @`0x2d6`=`[64,40,40,64]`,
  repérée via `41 EB <d16> E3 4F`. Ajoutés `DwLayout::channel_volume_offset` +
  `DwModule::channel_volumes`, émis en `TrackEffect::Volume` par note (variant
  `Old`). Zéro régression (gate strict OK, 15 tests dw + 26 player).
- **C8 empire volume → 96–100 % exact** (était 0 %, golden = player ×24/64
  uniforme). Master volume global STATIQUE `0x57a=24` appliqué à chaque écriture
  Paula (`AUDxVOL = volByte × 0x57a >>6`, au trigger 0x2bc ET à l'animation
  d'enveloppe par tick 0x404). Détecté via le prologue
  `MOVE.W (d16,PC),D2 ; MULU.W D2,D1 ; LSR.W #6,D1` (`34 3A <d16> C2 C2 EC 49`),
  mot maître à `instr+2+d16`. Ajoutés `DwLayout::master_volume_offset` +
  `DwModule::master_volume`, appliqué `× master/64` aux DEUX chemins
  (`scale_master_volume`, garde `master != 64`). **Généralise** : idiome présent
  dans 90/120 fichiers (les 2 sites pointent toujours le même mot) ; 84 ont
  l'identité master=64 (no-op gardé → zéro régression byte-exact, xenon2
  vérifié inchangé), 6 ont un master non-64 désormais mis à l'échelle fidèlement :
  empire 24, alfred chicken 24, chip's challenge/menace/wizzcat 40, jaws 48,
  cosmic pirate 63 (seul empire vérifié au cycle — c'est le seul golden). La
  garde est le PROBE pas le variant : qball (old) n'a pas l'idiome → intact.
- **C7 leviathan** = deltas petits et VARIABLES (mécanisme différent, basse
  priorité) — seul reliquat volume.
- **Suite** = offset période qball/C1 (arrondi table), puis period-space
  C2/C3/C7/C8 (gros morceau), puis deltas volume variables C7 leviathan.

### Avancement (2026-06-03, Session — arpège per-frame → lane TrackPitch Points)
Backlog #1-adjacent : l'**arpège** était projeté sur le `TrackEffect::Arpeggio`
classique (3-pas, mod-3, row-trigger) — faux pour DW dont l'arpège est **per-frame,
par-canal, continu** (Ghidra `tetris FUN_0000010e` 2-bracket + `bad company
FUN_000001b6` canonical : pointeur avancé chaque frame, offset → INDEX de la table
de périodes = espace demi-ton, reset only sur cmd `0x90`/`0xA0`). Désormais baké en
**lane `TrackPitch` `LaneKind::Points` de `AutomationValue::Pitch`** (1er producteur
réel du primitif cœur, zéro changement cœur) : simu per-frame `runtime.rs::
tick_arpeggio` (emit-on-change + skip du frame *gate* counter==1), producteur
`attach_arpeggio_pitch_lanes`, lecture player **per-frame** (`current_abs_tick +
current_tick_in_row` — sinon résolution row). Gaté `use_arpeggio_pitch_lane =
!(command_map && P2)` : `command_map` = la jump-table canonique (≈tout le corpus),
donc la lane couvre canonical + tetris + **bubble bobble** (migré depuis l'enveloppe
par-note : ch3 41.5→11.4) ; SEUL exclu = la famille **oscillateur C4** (grimblood,
unique `command_map+P2` ; Ghidra `FUN_00000d6e` = oscillateur période, pas un arp
index-table → la lane le régresse 6.8→21.9, gardé sur enveloppe ; cosmic pirate C4
mais P3 + arp_ev=0 = inoffensif). **A/B tetris ch1 60.5→3.9, ch2 49.0→28.6**
(résiduel ch2 = timing sous-row arm/re-arm = plancher row-quantifié), **bubble ch3
41.5→11.4**. **Zéro régression corpus** (les 9 reps byte-identiques, grimblood
restauré) ; suites vertes
(xmrs 288, player 32+8, gate 116, robustesse 116/0-panic). Transverse : ~45/116
modules arment un arpège (gros : archipelagos 426, fright night 416, buffalo 111).
L'arpège est **oracle-visible** (la trace sort `effective_period` = AUDxPER).
Détail → `BASELINE.md` + mémoire `project_xmrs_dw_arpeggio_pitch_lane`.

### Avancement (2026-06-02, Session — period-space C7/C8 RÉSOLU + C2 ch3)
Deux fixes Ghidra (importeur, zéro changement cœur), **mesurés en A/B propre**
(deux fixes OFF vs ON, 9 traces régénérées de chaque côté ; C1/C3/C4/C5/C6/
obliterator **byte-identiques** des deux côtés → zéro effet collatéral). ⚠️ C3
bubble bobble et C4 grimblood étaient **déjà corrigés en sessions antérieures**
et ne sont **pas** touchés ici. Détail + tableau dans `BASELINE.md`.
- **C7 leviathan / C8 empire utilisent le chemin période du NEW player**, pas
  le composite P1 de qball. Lu dans leur corps `play` (leviathan @0xda, empire
  @0x1ae) : `AUDxPER = table[note] × instr_mult >> 10` sur une table mot
  pleine-note (leviathan @0x320 = P2 ; empire @0x64a = P3), note/instrument
  découplés. Seule la *structure du flux/commandes* est « old ». L'importeur les
  forçait en `Old → P1` + composite `PERIODS1[note%12]` (capait tout en
  octave 0). Ajouté `DwLayout/DwModule::period_via_finetune` (posé quand
  l'idiome finetune `45 FA <d16> 32 2D 00 0A|0C 74 0A` apparaît sur un binaire
  *old* ; le `0C` = slot mult `instr+0xC` de leviathan) : décode note direct +
  finetune table. **A/B : C7 medΔ −105/+74/−105/−307 → −1/0/−1/0 ; C8 −93/−112/
  +42/−83 → −1/+2/+2/+2.** qball (sans idiome) reste composite.
- **Slot transpose-canal 0x3 non reconnu dans le command_map.** Le classifieur
  du jump-table ne matchait le handler transpose qu'au slot 0x2f (famille bubble
  bobble) ; la famille empire/bad-company écrit le transpose au slot **0x3**
  (`MOVE.B (A1)+,(0x3,A0)`, relu par `ADD.B (0x3,A0),D0`). Il tombait en
  `Unknown` (0 octet d'arg) → l'octet de valeur n'était jamais consommé → flux
  de CE canal désaligné. Maintenant matché (args=1). Affecte **exactement**
  empire (events transpose 0→26) et bad company (0→13) ; grimblood (pas de
  handler 0x3) et bubble bobble (slot 0x2f) inchangés. **A/B : empire ch0 −93→−1
  (son transpose +12) ; C2 bad company ch3 medΔ −110→+2 (seul canal faux).**
- Reliquat sur ces clusters = dérive vibrato/slide en espace période (MAE), plus
  de mismatch d'espace.
- **Volume leviathan (Task #9) FAIT.** Volume STATIQUE per-instrument écrit à
  chaque note (`MOVE.W (0xE,A5),AUDxVOL` @0x17c, pas d'enveloppe). Mot à
  `instr+0xE` des records 16o @0x380 (`[64,64,58,54,55×5]`), table localisée via
  l'idiome SetSample `4B FA <d16> C0 FC 00 10` (lea-A5 + `mulu #0x10`) →
  `instrument_volume_offset` ; lu dans `DwSample.volume` (gaté period_via_finetune),
  émis par note (effective_vol préfère le vol sample au lieu de channel_volumes
  pour ces players). A/B : vol exact 16/0/16/0% → 100/31/100/0%, MAE 5–9 → ~0–1.
  Empire (records 12o, `mulu #0xC`, vol par enveloppe) ne matche pas → intact ;
  qball/tetris inchangés. Suites vertes (xmrs 277 + gate 116 ; robustesse 116/0-panic).

---

## 1. Le « regroupement » : clusters de player

David Whittaker a fait évoluer son replayer ; le corpus se range par
**(variant, dispatcher, table de période, largeur ligne sous-chant, flags)**.
Travailler **par cluster** (fixer un représentant, propager) plutôt que par fichier.

| # | Cluster (clé) | Représentant | Effectif ≈ | Quirks notables |
|---|---|---|---|---|
| C1 | New · `[B0,A0,90]` · P3 | **xenon2 (title)** ✅Ghidra | ~55 | canonique ; `enable_channel_transpose` (Effect8) sur xenon2 |
| C2 | New · `[B0,A0,90]` · P2 | **bad company** | ~2 | (jupitermaster1) |
| C3 | New · `[C0,B0,A0]` · P3 | **bubble bobble** ✅Ghidra | ~26 | `volume_bracket_is_pitch` possible ; A3-rebasé : bubble bobble, alfred chicken, gunship2000, krustyssuperfunhouse, snow strike |
| C4 | New · `[C0,B0,A0]` · P2 | **grimblood** | ~2 | A3-rebasé (grimblood −0x388) ; cosmic pirate |
| C5 | New · `[C0,A0]` · P2 (famille tetris) | **tetris** ✅Ghidra | 8 | `enable_sample_transpose` (−3, stride sample-info 16) ; `volume_bracket_is_pitch` ; Effect8 = offset volume global. Membres : army moves, bmx simulator, exotic 210, kickstart ii, sidewinder, tetris, the hunt for red october, xenon |
| C6 | **Old** · P1 · `[]` | **qball** ✅Ghidra | 3 | note = `sample×12+pitch` ; pointeurs 32 bits ; `SetSample` inerte ; samples one-shot. `xenon-sfx`/`leviathan-sfx` = banques 0-track (instruments-only) |
| C7 | **Old**-stream · **P2** · `[C0]` | **leviathan-deabs** ✅Ghidra | 1 | flux old MAIS période NEW (`period_via_finetune`, table @0x320=P2, `×instr[+0xC]>>10`) ; note directe ; **vol per-instr statique `instr+0xE` FAIT** (table @0x380, `instrument_volume_offset`, vol exact) |
| C8 | **Old**-stream · **P3** · `[C0,B0,A0]` | **the empire strikes back** ✅Ghidra | 1 | flux old + période NEW (`period_via_finetune`, table @0x64a=P3, `×instr[+0xA]>>10`) ; transpose global(0x582)+canal(slot 0x3) ; arpège per-tick |
|| demi-volume | **obliterator-title** / `obliterator-ingame` | 2 | `enable_half_volume` (jump-table : slot8=`ST`, slot9=`SF`) |
|| sans ancre MULU | **millennium** | 1 | heuristique fallback (cf. detect.rs) |
|| canal vide (≈3 voix) | **sentinel** (`pos[0]=0`), sidewinder, super wonderboy | 3+ | une position-list de canal vide |

Représentants **chargés dans le projet Ghidra `xenon2`** (9, vérifié via
`list_open_programs`) : xenon2 (title), bubble bobble, tetris, qball, bad company,
grimblood, obliterator-title, leviathan-deabs, the empire strikes back. La
« session A » (bad company / grimblood / obliterator-title / leviathan-deabs /
the empire strikes back) est **faite**. (`anarchy sfx` n'est pas chargé — c'est
un des `dw_no_song` rejetés, sans intérêt de reverse.)

---

## 2. Stratégie de vérification (autonome → oreille en dernier)

Trois tiers ; les deux premiers sont automatiques.

### Tier 1 — Robustesse (zéro oracle, couvre les 116)
Étendre le gate test (`xmrs/src/lib.rs::load_all_dw_modules_from_music_dir`, ~l.194)
ou un nouveau test, pour **rendre** chaque module via `xmrsplayer` jusqu'à la fin de
sa boucle (cap raisonnable), et asserter :
- pas de panic / NaN / Inf ;
- pas de période hors plage Paula ;
- sur chaque canal qui porte des notes : RMS audio > seuil (pas de silence parasite) ;
- `verify_layers_consistent` OK (déjà testé).
⇒ capture l'essentiel de « fonctionner sans erreurs » sans aucune oreille.

### Tier 2 — Fidélité (oracle Ghidra, 1 représentant / cluster)
**Oracle = émulation PCode du replayer du binaire lui-même** (autorité absolue).
- Charger le représentant dans Ghidra (déjà fait pour C1/C3/C5/C6).
- Script (`run_ghidra_script` / `run_script_inline`, API Emulator 68k) : exécuter
  `init_main` (`0x66`) puis **N fois** `play_tick` (`0x1b6`) ; à chaque frame capturer
  les écritures registres Paula par voix `v∈0..4` :
  - `AUDxLC` = `0xdff0a0 + 0x10*v`, `AUDxLEN` `+0xa4`, **`AUDxPER` `+0xa6`**,
    **`AUDxVOL` `+0xa8`** ; `DMACON` `0xdff096`.
  - (Adresses confirmées dans la décompilation de `play_tick`.)
- **Côté nous** : ajouter un observateur per-tick léger dans
  `xmrsplayer/src/audio_observer.rs` (trait à la `ChannelsObserver`) exposant
  `period` + `volume` + `sample_index` par canal et par tick (≠ l'observateur PCM
  actuel). Rejouer le `Module` (1 tick player = 1 frame DW) et capturer la même trace.
- **Diff frame-à-frame** : période exacte en unités Amiga (tolérance ±1),
  volume exact (±1), même sample/voix on/off. Cible : **≥ 99 %** des ~3000 premières
  frames concordent ; consigner tout écart résiduel dans FORMAT.md.

### Tier 3 — Oreille (utilisateur, une seule fois, en fin de plan)
Liste finale : **un représentant par cluster** (8 fichiers) à écouter pour valider.
Pas de question par session — uniquement ce lot final.

---

## 3. Backlog de modélisation (priorité ↓), mappé aux clusters

1. **Vibrato en espace période** (tous clusters avec vib). Le replayer ajoute un
   triangle à la *période* (`sVar20 ± accumulateur`) ; on somme en demi-tons
   (`depth.raw()=max<<3`). Même correctif conceptuel que le slide qu'on vient de
   finir. *Le plus audible.* Oracle : trace vibrato sur xenon2/bubble.
2. **GlobalVolume / GlobalVolumeFade en lane continue** (`AutomationTarget::GlobalVolume`)
   au lieu de cuire la vélocité au row (clusters cmd 11/12 ; C5 via Effect8). Oracle :
   trace volume sur un module à fade.
3. **Enveloppe de volume à résolution frame** (tous) plutôt que row — pour les attaques
   rapides. Oracle : trace volume.
4. **`SetSpeed` (cmd 10) mid-song** → projeter `Speed`/`Bpm` + re-quantifier le mapping
   frame→row (sinon notes mal placées). Identifier les clusters concernés via la trace
   d'events (`events: … speed=N`). NB : depuis le fix grille-par-song (ci-dessous), un
   `SetSpeed` en cours de song re-casserait la grille fixe = `dw_speed_initial` ; pour ces
   modules il faudra une grille = PGCD des speeds rencontrés (bubble bobble a speed=0 → non
   concerné).

   **FAIT — grille de quantification par sous-song (ex-Task #10 « dernière song trop
   rapide »).** `to_module` quantifiait sur une grille FIXE de 3 frames/row, mais le pas réel
   des events est `dw_speed` frames (durée note = `(LongWait−0xDF)×dw_speed`). Quand
   `dw_speed` n'est pas multiple de 3 (bubble bobble songs 2/3 = speed 4/5), `floor((f−1)/3)`
   étalait les notes sur 1/2 rows irrégulières → jitter ±1 row (±60 ms), tempo moyen correct
   mais rythme bancal (66 % des onsets songs 2/3 off-grid). **Option A** : `speed`/`speed_u32`
   = `dw_speed` de la sous-song (shadow local dans la boucle de rendu) ; `speed_at_row =
   dw_speed`, `bpm_at_row = bpm_for(ss)` (le BPM continue de porter le delay-counter) ;
   `default_tempo` = speed de la song primaire ; `song_traces` porte le speed pour la passe
   lanes post-dedup. C'est l'idiome tracker exact (tempo=ticks/row=speed Whittaker,
   BPM=cadence des ticks). Le runtime honore déjà un tempo par-song via
   `TimelineEntry::speed_at_row`/`bpm_at_row` (sequencer.rs:331/406) → **zéro changement
   core**. Vérifié : NoteOn off-grid=0 sur les 4 songs ; song 0 (passée grille 3→6) matche
   toujours l'oracle Paula (gate 93-98 %, médΔ +2..+5 = offset concert) ; gate corpus 116
   modules vert. Conserve l'arpège classique XM (≥4 ticks/row) — contrairement à une grille
   1 frame/row. Corrige aussi tout module du corpus à speed non-multiple-de-3.
5. **Half-volume** (cluster obliterator) — modéliser le toggle Effect8/9 `ST`/`SF`.
6. **Canaux vides / 3 voix** (sentinel, sidewinder, super wonderboy) — vérifier que
   `get_num_channels` et le rendu gèrent une position-list vide.
7. **Square-waveform** (`enable_square_waveform`) — instruments/vibrato carrés.
8. **`Effect9` RestartPosition non nul** (saut mid-liste) — si un module du corpus le
   déclenche (à confirmer via trace).
9. **Old player C7/C8** (leviathan-deabs, the empire) — valider dispatcher 1- et
   3-brackets sur variante Old (note composite, périodes P1).

10. **Bouclage par-canal (voix indépendantes qui dérivent).** Confirmé par Ghidra sur
    `xenon2 (title).dw` : `play_tick` cmd 0x80 reboucle CHAQUE canal à sa propre liste
    (`*(short*)(0x600)`, base du canal), **aucun redémarrage global**. Les 4 voix ont des
    longueurs de passe inégales (9888/9888/9912/10014 frames) → elles se déphasent à
    l'infini (fidèle au matériel). Le modèle xmrs n'a qu'un wrap global (`song_loop_to`),
    d'où un wrap brutal au cap 20 min.
    - **Incrément 1 FAIT** (importeur) : quand `run_with_loop` bute sur le cap (aucune
      boucle commune ≤ cap), wrap à la **voix la plus longue** (`max(first_wrap−1)`) au lieu
      du cap → rien perdu, boucle ~3,3 min, voix longue *seamless*, résidu = petit stutter
      des voix courtes au wrap. Plus scaffolding cœur inerte (`ChannelLoop`,
      `Module.channel_loops`, `row_at`/`tick0` fold gatés, `entry_at_tick`) validé par tests.
    - **Incrément 2 FAIT** : `channel_loops` peuplé par voix (branche drift) + free-run du
      sequencer (`loop_generation`/`song_tick_span`/`current_abs_tick_monotonic`,
      `advance_to_next_entry` reboucle la timeline sans wrap global) + `row_at_with_playhead`
      threadé avec le tick monotone côté lecteur → **dérive infinie sans aucun saut**.
      Vérifié xenon2 (ch0/ch1 périodiques 9888 en continu à travers 10014/20028). Gaté
      `channel_loops` vide → XM/IT/MOD intacts. Voir `daw/LOOP_REGION_RFC.md`.

Chaque item est **oracle-vérifiable** par diff de trace Tier 2 sur son représentant.

---

## 4. Découpage en sessions (chacune autoportante, livre + commit)

- **Session A — Harnais & baseline.**
  1.**Fait** — Tier 1 : test de rendu robustesse sur les 116 (0 panic, 0 échec ;
     1 résiduel volume-lane signalé : emlyn ch0). Cf. avancement §0.
  2. Tier 2 :
     -**Fait** — script Ghidra d'émulation `init_main`+`play_tick` avec capture
       Paula par `MemoryAccessFilter` (`dw/oracle/DwPaulaOracle.java`, doc
       `README.md`, golden `xenon2_title.csv`). Reproductible, validé. Cf. avancement §0.
     - **Reste** : observateur per-tick (`audio_observer.rs`) + trace côté player ;
       charger les 5 représentants manquants ; diff trame-à-trame → **`BASELINE.md`**
       (rapport par cluster) committé ici.
  - *Livrable* : harnais + baseline. Aucune correction de fidélité encore.

> ⚠️ **Le travail n'a pas suivi cet ordre de sessions** — les items ont été
> cherry-pickés par impact. Statut réel par item ci-dessous (vérifié code +
> notes d'avancement, 2026-06-03). Le découpage B/C/D reste indicatif.

- **Session B — Fidélité lot 1 : vibrato période + GlobalVolume lane.**
  - #1 vibrato période : ⏳ **OUVERT** — c'est LE reliquat de fidélité (dérive
    vibrato/slide en espace période, cf. avancement period-space).
  - #2 GlobalVolume lane continue : ⏳ **non fait comme lane**. Mais le volume a
    été largement résolu autrement (avancement « Session B / volume » :
    tetris/qball/empire + leviathan Task#9 — vol per-instr/canal/master).

- **Session C — Fidélité lot 2 : enveloppe frame-res + SetSpeed + half-volume.**
  - #3 enveloppe frame-res : ✅ **FAIT** (`volume_to_volume_envelope`, 1 point/frame).
  - #4 SetSpeed : ✅ grille de quantif par-song FAITE ; ⏳ SetSpeed **mid-song**
    (re-grille PGCD) non fait.
  - #5 half-volume : ✅ **détection FAITE** (`enable_half_volume` via effect
    jump-table ; largeur d'`Effect9` corrigée).

- **Session D — Lot 3 + balayage final : canaux vides, square, Effect9, old player.**
  - #9 old player C7/C8 : ✅ **FAIT** (`period_via_finetune`, leviathan/empire).
  - #6 canaux vides / #7 square / #8 Effect9-restart : ⏳ **OUVERTS**.
  - **Balayage de trace** final + liste d'écoute Tier 3 : ⏳ à produire.

Chaque session se termine par : `cargo test` (les 2 crates) + diff de trace verts,
MAJ FORMAT.md, commit.

---

## 5. Critères d'acceptation (gate test final)

- **Chargement** : 116/116 chargent + `to_module` + `verify_layers_consistent` ; 4
  rejetés `dw_no_song` (inchangé).
- **Robustesse** : 116/116 rendus jusqu'à fin de boucle via `xmrsplayer`, sans
  panic/NaN, RMS sain sur les canaux à notes.
- **Fidélité** : pour chaque représentant de cluster, trace Paula période exacte
  (±1) sur ≥ 99 % des ~3000 premières frames, volume ±1 ; résiduels documentés.
- **Oreille** : lot Tier 3 validé par l'utilisateur.

---

## 6. Carte des fichiers & ancres (exactes)

- Importeur : `xmrs/src/tracker/import/dw/dw_module.rs`
  (`DwModule::load`, `to_module` ~l.249, `attach_vibrato_lanes` ~l.1272,
  `attach_slide_lanes` ~l.1420).
- Runtime/simulateur : `xmrs/src/tracker/import/dw/runtime.rs`
  (`apply_event` ~l.598, `tick_channel` ~l.512, `ChannelState`, `TickEvent`).
- Détection/clusters : `xmrs/src/tracker/import/dw/detect.rs` (`DwFeatures` l.37 :
  `enable_*` ; axes de détection cf. FORMAT.md §10).
- Décodeur d'events : `xmrs/src/tracker/import/dw/event.rs` (`DwTrackEvent`, l.27).
- Player : `xmrsplayer/src/channel/lanes.rs` (`apply_slides_from_lanes` ~l.260),
  `xmrsplayer/src/audio_observer.rs` (ajouter l'observateur per-tick).
- Quirks : `xmrs/src/core/compatibility.rs` (`PlaybackQuirks`).
- Gate test : `xmrs/src/lib.rs::load_all_dw_modules_from_music_dir` (~l.194).
- Outils : `xmrs/examples/inspect_dw.rs` (triage ; `-q` résumé ; `DW_TRACE=1` trace),
  `xmrs/examples/dump_dw_pattern.rs` (grille rendue par pattern/canal).
- **Ghidra (oracle, source de vérité unique)** : `play_tick` `0x1b6`, `init_main`
  `0x66`, `sample_loader` `0x1414` ; registres Paula `0xdff0a0 + 0x10*v`
  (+`0xa6` PER, +`0xa8` VOL), DMACON `0xdff096`. Programmes chargés :
  `list_open_programs`. Émulation Paula tick-exacte : `oracle/`.

---

## 7. Commandes types

```sh
# Triage / clusters (résumé une ligne par fichier)
cargo run --release --features=demo --example inspect_dw -- -q ~/Music/dw/*.dw
# Trace d'events complète d'un module
DW_TRACE=1 cargo run --release --features=demo --example inspect_dw -- "~/Music/dw/<f>.dw"
# Grille rendue (pattern, canal)
cargo run --release --features=demo --example dump_dw_pattern -- "<f>.dw" <song> <pattern> [ch]
# Gate test
cargo test --release --features=demo load_all_dw_modules_from_music_dir
# Suites complètes
( cd xmrs && cargo test --release --features=demo ) && ( cd xmrsplayer && cargo test --release )
```