26 KiB
game471.exe: Notes (Static Analysis)
Goal: understand file formats and runtime behavior for a clean-room reimplementation.
This is based on static inspection (strings + disassembly). No patching, no runtime hooking.
Quick Map
-
Stage file path templates referenced in code:
data/stage/%s.datdata/stage/%s_ext.datdata/stage/%s_clip.dat
-
The game uses a background file reader (a small work queue + thread) that:
- Opens a file path stored in a request object.
- Reads the file into a buffer in 64 KiB chunks.
- Calls a callback function provided when the queue was created.
Background File Reader
Worker thread proc
0x004ca320is used as the thread entry function (passed as a function pointer).- It receives a small argument struct, extracts two pointers from it, then calls
0x004ca070.
File read loop
0x004ca070implements:- open file (
CreateFileA), thenReadFilein a loop - destination buffer is
request->bufat offset+0x204 - expected size is
request->sizeat offset+0x208
- open file (
At several points it calls a callback:
queue->callback_fnstored at offsetqueue + 0x120queue->callback_ctxstored at offsetqueue + 0x124(passed as last argument)
Queue initialization
0x004ca5f0looks like the queue constructor/initializer.- It stores its arguments into
this+0x120andthis+0x124.
So, to find the actual parsing logic for a specific file type, you typically follow the callback function pointer passed into the queue creation.
Stage Loader Entry
There is code that formats the stage file path strings and attempts to open:
data/stage/<name>.datdata/stage/<name>_ext.datdata/stage/<name>_clip.dat
This is a good anchor when looking for where the game requests reads for stage containers.
Static addresses in the 4.71 executable:
0x0063ea70: formats all three stage paths and requests each resource.0x00427730: resource request wrapper.0x004279d0: creates the 0x4c-byte resource object (Ghidra mislabels it as an MFCCreateObject).0x0063ecc0: completion/cleanup callback which unwraps three resources before passing the result onward.
Main stage header
The file is big-endian. The initial words are not all section offsets. Confirmed section slots are:
| Index | Meaning |
|---|---|
| 0 | stage config |
| 1 | track draw distances |
| 2 | track points |
| 3 | notes |
| 4 | camera |
| 5 | TuneBGEffectData / FlowItem keys |
| 6 | visualizer |
| 7 | background texture names and image keys |
| 8 | first color table |
| 9 | objects |
| 10 | scalar/unknown; often 0x30, not an offset |
| 11 | points into the post-object extension offset table |
| 12 | scalar/unknown; can accidentally look like an in-file offset |
This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary.
The post-object extension base is the reader position after the variable
object stream. Relative offset slot 1 extends background colors and slot 2
stores object parents. Deriving that base from consumed bytes, as Android
LoadBGData does, also handles the small mobile chart revision correctly.
Note array: confirmed wire layout
The note section starts at header word 3 and ends exactly at header word 4. Its layout is:
u32be name_count // observed: 0 in all 2,968 dumped main charts
repeat name_count times:
string8 name // u8 length followed by bytes
u32be count
repeat count times:
u32be time_ms
u8 raw_type
u8 type_override
s16be params16[9]
u8 flag24
f32be params25[3]
u8 flag37
u8 flag38
f32be params39[4]
u32be params55[3]
f32be param67
u32be param71
f32be params75[5]
u32be param95
Thus each note record is exactly 99 bytes and the invariant is:
camera_offset - notes_offset == 8 + encoded_names_size + count * 99
The invariant holds on tested charts of very different sizes. For example,
ac_10pt8tion_hard.dat has 669 records from 6162 through 125568 ms.
Its raw-type histogram is 01:366, 02:231, 03:1, 04:3, 05:3, 09:63, 0f:2.
Observed raw types over the parsed corpus are 00, 01, 02, 03, 04, 05, 06, 09, 0a, 0f.
The payload field meanings remain unnamed until backed by code references or
controlled runtime observations.
The exact read order above comes from the game's stage constructor 0x005ed4c0
and its note-section parser 0x005ea800. The latter allocates a 0x11c-byte
runtime object for every compact 99-byte wire record. It also proves one behavior:
if type_override is non-zero, the runtime replaces raw_type with type 1.
The post-load pass at 0x005ebaa0 additionally proves that runtime types 11,
12, 13 and 14 are compatibility aliases: 11 becomes 10, 12/14 become 9, and
13 becomes 4. Other game modes can deliberately simplify types (for example
2 to 1, and 4/5/10/15 to 3), so a clean player should preserve both the raw
wire type and the effective runtime type.
Full-dump validation with tools/scan_gc_notes.py GC/data/stage covers 2,968
main stage files and 1,080,259 notes with zero size mismatches. All of those
files currently have zero note-name strings. The aggregate raw-type counts are:
00:6 01:719892 02:163462 03:67753 04:8651
05:5115 06:193 09:102699 0a:6360 0f:6128
type_override is non-zero in 52,506 records, so it cannot be discarded.
Confirmed type names
The executable contains a direct pointer table at 0x0077d480; its array index
is the numeric note type. This yields the complete original enum:
| Value | Original name |
|---|---|
| 0 | NONE |
| 1 | NORMAL |
| 2 | FLICK |
| 3 | HOLD |
| 4 | SCRATCH |
| 5 | BEAT |
| 6 | MERRY GO ROUND |
| 7 | HIDDEN |
| 8 | HIDDEN2 |
| 9 | CRITICAL |
| 10 | SLIDE HOLD |
| 11 | SLIDE COUNTER |
| 12 | TURN |
| 13 | SPIN |
| 14 | FINISH |
| 15 | DUAL HOLD |
The same 16-entry sequence is repeated in several embedded reflection/debug tables, which independently confirms the ordering.
Confirmed runtime classifications
With the exact names assigned, several behavioral groups are also explicit in code:
0x005e9480classifies types 3, 4, 5, 10 and 15 as duration/path notes.0x005ebaa0generates sampled paths for those types. Type 4 additionally builds two derived point arrays; types 3/10/15 share one path branch.0x005e94c0singles out type 6. During gameplay setup it expands that note into several runtime judge objects using the runtime field originating from wire offset+71.- Types 1, 2 and 9 follow the non-duration branches in the preview/gameplay
render path at
0x006492f0.
The enum names are now exact. The remaining reverse target is the meaning of the compact payload fields and the type-specific hold/slide behavior.
Appearance, duration and control-helper effects
The post-load pass at 0x005ebaa0 converts several compact fields using the
BPM active at the note timestamp. With beat_ms = 60000 / bpm:
| Wire field | Runtime meaning |
|---|---|
+6 |
signed marker-effect/UV selector; the note-head draw passes value - 1 to effect 3 |
+39 |
appearance lead in beats; runtime +0xbc = max(time - value * beat_ms, 0) |
+37 |
enable positional fly-in |
+38 |
draw the additive fly-in trail |
+43 |
fly-in interpolation start, in beats after appearance |
+47 |
fly-in interpolation end, in beats before the hit time |
+51 |
duration in beats for types 3/4/5/10/15; runtime +0x58 and +0xac = time + value * beat_ms |
+55 |
primary packed authored RRGGBBAA target colour; also used by duration geometry |
+59 |
secondary packed RRGGBBAA colour |
+63 |
integer fly-in oscillation count |
+67 |
fly-in oscillation endpoint/phase scalar |
+71 |
number of generated MERRY GO ROUND targets |
+75 |
MERRY GO ROUND spacing in beats |
0x005efe40 confirms that MERRY's span is
count * spacing * beat_ms. Gameplay setup allocates three visual handles per
generated target, which explains why treating type 6 as one marker loses most
of the authored pattern.
The four blocks following the BPM table in StageConfig are variable-length
timing lists, not four fixed records. Each list is u16be count, followed by
count entries of u32be time_ms, u32be mode, f32be value.
TuneTimingData::GetTime selects the last entry at or before the note: mode 1
returns value as milliseconds, mode 3 returns the spacing to the next note,
and the other modes return value * beat_ms. The third list supplies the late
boundary used as runtime +0xac for ordinary targets. FLICK (and raw type
0x10) receives an additional literal 0.2 * beat_ms.
Marker opacity is independent of the fixed judgment-window overrides below.
0x0063ff90 stores beat_ms at runtime +0x20; 0x0064ab80 divides it by
the literal 2.0, while 0x005ebaa0 sets runtime +0xc4 to
+0xac + 4 * beat_ms. With start = +0xbc, finish = +0xc4, and
fade = beat_ms / 2, the exact unclamped shape is:
time < start + fade : alpha = (time - start) / fade
time <= finish - fade : alpha = 1
otherwise : alpha = (finish - time) / fade
The renderer clamps this to [0, 1]. Duration targets use their authored end
as +0xac; MERRY GO ROUND first adds its generated-target span. The Linux
player now carries the per-note BPM and +0xc4 equivalent instead of using
the former guessed 236 ms marker lifetime.
The lower-right control helpers are not note-head effects. 0x00661680 reads the 16-entry
table at 0x006ea040, loads the indicated efcdata.dat record, and stores it
in runtime slot 0xb1 + type:
type: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
effect id: -1 61 66 62 63 64 61 -1 67 65 69 -1 -1 -1 -1 68
Thus NONE and HIDDEN have no helper resource. FLICK and SLIDE HOLD also draw
runtime slot 0xb9 as a direction overlay in 0x006492f0. The leading words
of effect records 61--69 give animation lengths of 16, 38, 34, 13, 16, 14,
14, 38 and 38 update ticks respectively.
The common-effect interpreter is now mapped far enough to reproduce those
records exactly. 0x005f2800 dispatches the child nodes, while
0x005f2030, 0x005f2250 and 0x005f23f0 implement looping, key lookup and
step/linear interpolation. Every child has five relative track offsets. A
track begins with u16be key_count, loop_start, loop_end, followed by keys of
u16be time, u8 interpolation, f32be values[]. For a sprite child (type 2),
the tracks are:
| Track | Values |
|---|---|
| 0 | UV frame plus six unused/secondary values |
| 1 | XYZ position |
| 2 | alpha, red, green, blue |
| 3 | XY scale |
| 4 | rotation in degrees |
Type 0 is a parent rotation node in radians. A sprite whose child flag at
+4 is set inherits that rotation, including the rotated XY offset. Effects
61--69 use UV record 13 from uvdata.dat, whose image is img13.bin: target
rings, balls, hand prompts, arrows, action bursts and the type-specific help
caption are separate animated layers. 0x006492f0 advances the records with
an integer 60 Hz tick, places the selected composite at the literal virtual-
screen coordinate (580, 788, 0) and applies the literal global XY scale
1.3. 0x006e9ee4 supplies a 1000 ms look-ahead, so only the current helper
is selected; the effect is not cloned onto every world-space note. The Linux
player implements this format
in vectorail-gc/src/GcTargetEffect.cpp; it does not substitute a
single guessed icon for the composite any more.
Cross-version confirmation of note-head rendering
The Android ARM64 build preserves C++ symbols which are stripped from the
arcade executable. Its GameScene::DrawGameStageCharacter establishes the
same ordering seen in game471: it disables depth writes, calls DrawWay,
then calls DrawMark without disabling the depth test. The route therefore
cannot punch holes through marker sprites drawn afterwards.
TuneGameData::BuildTimingDataSub (0x00196a74) fixes the note head's world
position before gameplay by calling GetWayPosition(note_time) directly and
storing the returned XYZ at runtime +0xe0. Long-note endpoints likewise use
GetWayPosition(end_time) at +0xf8. There is no conversion through physical
track distance and no note-time lead added in this path. DrawMark consumes
these stored positions. The separate one-frame -16.6667 ms lookup in
GameScene::SetCommonParam is used for the moving player segment, not for note
placement.
The Linux player now performs the same direct timestamp lookup for heads,
endpoint heads, generated BEAT/MERRY targets, and approach circles. Its shared
physical-distance LUT is still needed for duration-body sampling; it now
samples each authored segment independently and includes every exact corner.
The previous cumulative t += 0.02 loop could jump across a segment boundary,
measure a shortcut chord through the corner, and progressively place later
distance-based geometry ahead of the authored route position.
GameScene::DrawMark creates each head with effect manager group 1,
effect 3, and the final selector wire_mark_effect_id - 1. Group 1 is bound
by GameScene::LoadSkinData to the selected skin's uv.dat and its
texture list. The first signed word at stage-note wire offset +6 contains
the selector source. Examples from ac_10pt8tion_hard.dat are:
| Note | Wire +6 |
Resolved UV record |
|---|---|---|
| NORMAL | 1 | 1 |
| HOLD | 9 | 9 |
| FLICK | 10 | 10 |
| SCRATCH | 11 | 11 |
| BEAT | 12 | 12 |
| CRITICAL | 32 | 32 |
| DUAL HOLD | 37 | 37 |
Effect 3 has a 15-tick loop. Records 1--12 use 32x32 cells from the common
skin atlas, while records 32 and 37 use 64x64 cells from the second common
atlas. The effect instance uses the literal XY scale 0.025; consequently
the authored 32px and 64px cells occupy 0.8 and 1.6 route-world units.
Directional heads have a second world-space layer. DrawMark plays effect
39 over FLICK with UV base 0 and over SLIDE HOLD with UV base 10. The final
root angle is not wire +29 used directly: wire +25 is a vector length and
+29/+33 are HPB angles. BuildTimingDataSub converts these through
RotateHPB::ToVector_Deg; GameScene::buildGameData projects the route point
and the displaced point with the camera evaluated at the note timestamp, then
uses RotateHPB::SetVector (atan2(screen_dx, screen_dy_down)) and stores the
resulting screen angle. It is computed before gameplay and remains fixed while
the live camera moves. Effect 39 resolves to a 128x32 animated strip whose
arrows extend to the sides of the 32x32 head; it uses the same 0.025 scale
colour and marker alpha. This is independent of the lower-right control
helper, even though that helper also has a direction overlay.
The marker animation clock is beat-synchronized, not a free-running 60 Hz
counter. Android GameScene::SetCommonParam computes
phase = 1 - (((time - bpm_change_time) / beat_ms * 2000) % 2000) / 2000
and DrawMark writes phase * EffectData::GetFrameMax() to effect field
+8. Thus effect 3 runs backwards from frame 15 toward frame 0 once per beat
and resets at every beat; BPM changes also reset the phase origin. BEAT adds
sample_index * 2 to this base frame.
The approach ring is geometry, not another atlas layer.
GameScene::drawHitTimingCircle draws a white 16-segment circle with additive
blending, radius = 0.2 + progress * 0.5, and alpha marker_alpha * 128/255.
For ordinary notes progress = (late_boundary - time) / (2 * (late_boundary - early_boundary)),
capped at 1 and only shown before
the target time. The arcade MERRY branch instead evaluates the analogous
progress separately at each generated target's shifted timestamp.
Both that circle and the effect-3 head are camera-facing billboards. The
mobile renderer calls SetTransposeMatrix(world_position, camera_matrix) for
the circle and leaves the effect sprite in billboard mode; neither is laid in
the route tangent plane. Duration bodies remain authored 3D route geometry.
The Linux player now decodes this selector, interprets effect 3, loads
skin/common/common.png, skin/common/common2.png, the selected
skinN/img.dat list and skinN/uv.dat, and renders the selected animated
UV record. The former guessed effect/game/img4.bin glyph mapping and
synthetic pulsing rings are no longer used for decoded GC stages.
Duration bodies are also type-specific in the executable:
- Android HOLD does not submit the prebuilt ribbon array. Its normal gameplay
branch calls
DrawWay(max(current_ms, appear_ms), end_ms, color, color)with additive blending and a beat-reactive width from 3 to 5 pixels. This exact moving timestamp boundary is why dropping whole prebuilt samples produces a visibly stepped disappearance. Arcade still constructs HOLD helper geometry, but it is not evidence for the mobile draw path. - SLIDE HOLD calls its prebuilt triangle-ribbon path.
- SCRATCH is sampled every
0.15world units.0x005ebaa0derives two opposing paths with radius0.2and rotates the offset by 45 degrees per sample;0x00641d50emits six vertices per segment for each path. - BEAT is sampled every
0.55world units and0x0064ab80creates a separate effect at every point. There is no continuous BEAT body. Its case-5 loop draws only points whose sampled timestamp has not passed, and writesbase_frame + sample_index * 2into effect field+8. Android's symbolizedEffectDataaccess confirms this field is the animation frame; the authored UV-list selector remains unchanged. - MERRY GO ROUND computes the
+0xbc…+0xc4alpha once outside its generated- target loop and multiplies every copied effect by that same parent alpha. Individual offsets from0x005efe40change target time and position, not the fade interval. - DUAL HOLD uses
0x00646d70to build two ribbon bands. Its literal inner and outer offsets are0.2and0.5. - Android
GameScene::drawLongShotEffect(matching arcade0x0064ab80) draws an effect-3 marker at the authored endpoint as well as the starting marker. The fixed endpoint UV bases are 29 for HOLD, 30 for SCRATCH, 41 for SLIDE HOLD, and 42 for DUAL HOLD. SLIDE HOLD additionally layers effect 39 with UV base 10 at the endpoint and rotates its root by the precomputed screen direction. Both endpoint layers inherit the long note's colour, fade alpha, billboard transform, beat-synchronised animation and0.025sprite scale.
Positional fly-in
GameScene::DrawMark converts wire +25/+29/+33 into direction vector D and
starts from the authored route position P. With:
start = max(0, appear_ms + field_43 * beat_ms)
end = max(0, note_ms - field_47 * beat_ms)
u = 0 before start, (now-start)/(end-start) in between, 1 at/after end
0x005ebaa0 first stores runtime +0xc0 = ROUND(appear_ms / frame_ms).
0x0064ab80 then computes the start from that stored integer, but computes the
end from the still-fractional note_ms / frame_ms; each final boundary passes
through another nearest-integer conversion at 0x0050a4a0. The live clock is
quantized the same way.
Flag +37 selects positional interpolation. With no oscillations the displayed
position is P + D*(1-u). When integer cycles = +63 is positive, let
c = +67 for values up to 1 and c = 2-(+67) otherwise; then position is
P + D*(c - abs(sin(u*(asin(c) + cycles*2*pi)))). Integer cycles guarantee
that the marker lands back on P at u=1. The approach circle follows the
same moved position. Flag +38 draws an additive line from the initial external
anchor to the moving marker before the hit time. The player preserves that
nearest-frame 60 Hz quantization rather than smoothing the movement on the
audio clock.
Tap judgment timing
The system-config loader at 0x00635c90 lays out the timing overrides in four
per-difficulty arrays. The stage loader applies them in 0x005ed4c0, and the
post-load pass at 0x005ebaa0 derives these runtime note boundaries:
| Runtime field | Boundary | Easy override |
|---|---|---|
+0x98 |
note time minus MissTimingOverride |
236 ms |
+0x9c |
note time minus UnmuteTimingOverride |
202 ms |
+0xa0 |
note time plus LimitTimingOverride |
202 ms |
+0xa4 |
note time plus MuteTimingOverride |
0 ms |
0x005d1690 is the tap-grade function. It selects +0x9c for an early tap or
+0xa0 for a late tap and computes the absolute distance W from the note
time to that boundary. It then builds thresholds using the literal table
{100000, 100, 50, 25} at 0x006e8bc0, divided by 100. The grade loop checks
indices 3 down to 1, which matches the adjacent original enum/string order
MISS, GOOD, COOL, GREAT.
For an error magnitude e and the selected outer input distance W, the
unclamped comparisons are therefore:
GREAT: e < 0.25 * W
COOL: e < 0.50 * W
GOOD: e < 1.00 * W
GreatMinTime is copied into the gameplay object at +0x104; if the computed
GREAT threshold is below that value, it is clamped and COOL is redistributed
by the same routine to great + (good - great) / 3, capped at GOOD. With the
shipped Easy W = 202, the absolute-error thresholds are 50.5 ms for GREAT,
101 ms for COOL, and 202 ms for GOOD. HARD/EXTRA use W = 134, producing
33.5/67/134 ms. The shipped GreatMinTime = 32 therefore does not clamp these
four standard difficulty windows, but the branch matters for custom configs.
MissMarkOverride=1 in the shipped data/system.cfg makes the stage loader
replace the timing-list results with these four Simple/Normal/Hard/Extra arrays:
MissTimingOverride = (236,202,168,168)
UnmuteTimingOverride = (202,168,134,134)
LimitTimingOverride = (202,168,134,134)
MuteTimingOverride = (0,0,0,0)
SHOT miss gating
The two stage WAVs remain sample-synchronized, but they are not mixed at a
fixed level. FUN_00640790 reads each player's IsMute state after the
judgment update. For player zero, FUN_00611920/FUN_00611940 pass 1.0 or
0.0 through FUN_00611800 to the third volume multiplier of channel 13
(SHOT); channel 12 (BGM) is untouched. The write is immediate rather than a
timed SoundManager fade.
An unresolved target closes the SHOT gate at runtime +0xa4, which is note
time plus MuteTimingOverride (zero in the shipped configuration). The note
remains judgeable through +0xa0; a successful late judgment opens SHOT again
immediately. This is why a miss removes the chart-performance layer without
stopping or seeking either WAV.
Gameplay tap and AD-LIB sound effects
The hit sounds are a separate three-WAV sound set; they are not baked into
either stage stem. FUN_00613430 resolves the selected entry from
data/boot/se.dat and loads its three names from record fields +0x14,
+0x18, and +0x1c. The default se0000 ("Ver.3 Set") maps them to:
| Slot | WAV | SEList.csv volume |
Playback channels |
|---|---|---|---|
| 0 | SE_ARRANGE.wav |
87 | 6, 7 |
| 1 | TAP_SE1.wav |
80 | 10, 11 |
| 2 | TAP_SE2.wav |
77 | 8, 9 |
Every slot is deliberately double-buffered. FUN_00611fc0 round-robins slot
0 over channels 6/7. FUN_00611e70 round-robins the two booster sounds over
10/11 and 8/9. Consequently consecutive taps overlap instead of cutting off
the previous sample. Gameplay update FUN_00640790 consumes the two booster
edge flags at player fields +0xed/+0xee and the separate AD-LIB flag at
+0xaa; the booster edge is what starts TP1/TP2, before the resulting note
rank is known. A mistimed press still produces its tap sound. The AD-LIB flag
selects slot 0 independently.
The other se.dat entries preserve the same three-slot layout, for example
ALB_06/TP1_06/TP2_06 for the Scratch set. The chosen set id comes from the
player customization state (Afx state +0xce0 in the gameplay loader), not
from the stage chart.
The Linux player currently loads the default Ver.3 set at the exact CSV gains
and reproduces the original two voices per slot. It decodes serialized marker
byte +5 into the runtime +8 AD-LIB flag and starts ALB after a successful
rank on such a marker. D-pad inputs are treated as booster 1 and face-button
inputs as booster 2; this also matches the PSP-style control grouping.
Ranked hit effects
setRankedEffect records the rank and gameplay timestamp. The later
DrawGameStageCharacter pass selects group-1 effect rank + 0x1c, so the
effect IDs are 29 MISS, 30 GOOD, 31 COOL, and 32 GREAT. Their decoded lifetimes
are respectively 20, 12, 12, and 12 frames. The effect advances from the hit
timestamp at 60 Hz, uses additive blending, a global XY scale of 0.03, a
camera-facing matrix, and a stored random root rotation from rand() % 360.
Hit-type dispatch and long-shot ranks
The arcade dispatcher at 0x005d5fb0 selects the handler from the effective
marker type: 1 TAP, 2 FLICK, 3 HOLD, 4 SCRATCH, 5 BEAT, 6 MERRY, 9 CRITICAL,
10 SLIDE HOLD, and 15 DUAL HOLD. Android's symbolized
GameScene::checkHitMark independently confirms the same mapping and names
type 9 checkHitMarkDualTap.
Dual tap stores the timestamp of its first independent input, consumes a second independent input, then grades using the first timestamp. DUAL HOLD also waits for two independent inputs, but begins its measured hold duration when the second arrives; releasing either side ends it.
Arcade FUN_005d0d80 grades HOLD and DUAL HOLD by the percentage of the
authored body actually covered after clamping press/release to its start/end:
GREAT: held > 80%
COOL: held > 60%
GOOD: held > 40%
MISS: otherwise
The comparisons are strict. Before computing the percentage it also applies
the original short-tail compensation: if 20% of the body is below
66.666664 ms, that difference is added to covered time. FUN_005d0eb0
finalizes a still-pressed hold at the authored end; an early release grades it
immediately.
BEAT (FUN_005d41b0) uses the same long-shot percentage grader, but records
the first tap and continually replaces the covered endpoint with each later
tap. If no fresh trigger arrives for BeatEnableTime (200 ms in the shipped
config), it finalizes at the last trigger. Thus it measures how far through the
body the repeated taps continue, rather than merely counting taps.
SCRATCH (FUN_005d44f0) listens to four directional triggers. The first one
starts coverage; each later trigger extends it only when its direction differs
from the previous accepted direction. It finalizes after ScratchEnableTime
(250 ms) without a direction change or at the body end, then uses the same
40/60/80-percent long-shot grader.