diff --git a/PERF.md b/PERF.md index 10e81d0..786266c 100644 --- a/PERF.md +++ b/PERF.md @@ -5,6 +5,79 @@ same-day captures of all four ports. This supersedes the 2026-07-04 Phase 0 baseline; every phase of the audit fix plan (0-8) has landed, so these are the numbers the library actually ships with. +**2026-08-06 update -- Sharp X68000 moved to the 256-colour graphics +plane.** The port previously drew native-planar into the TEXT plane, which has a +single 16-entry hardware palette and therefore could not honour the SCB. To make +SCB per-band palettes work it now presents into the 256-colour GRAPHICS plane +(`_iocs_crtmod(13)`), expanding the four 1bpp planes to one byte per pixel in +GVRAM with the DOS port's encoding, `(scb[y] << 4) | nibble`, against the +256-entry palette at `$E82000`. Storage is UNCHANGED -- still +`JOEYLIB_NATIVE_PLANAR`; only the present target moved. No raster interrupt is +involved. All 51 golden hashes are unchanged (jlSurfaceHash hashes the surface +planes, not GVRAM) and the port stays at 51/51. (The "total wall time is +identical at 15945 ms" originally written here was an artifact of the broken +frame clock -- see the clock note at the end of this entry.) + +Measured 2026-08-06 on the passing 256-colour run with both clocks fixed. +Honest UBER total wall time is **40890 ms (2249 frames)**. Two earlier figures +in this entry's history were wrong and are corrected below: 15945 ms came from a +broken frame clock, and 108309 ms was real but predated the present rewrite. + +The "pre-LUT" column is the straightforward per-pixel expansion the graphics-plane +switch first shipped with; "now" is after the word-store expansion tables. + +| op | pre-LUT | now | IIgs | now / IIgs | +|---|---|---|---|---| +| `jlSurfaceClear` | 30 | 106 | 33 | 3.21x | +| `jlPaletteSet` | 16175 | 58516 | 1561 | 37.49x | +| `jlScbSetRange` | 5946 | 21206 | 163 | 130.10x | +| `jlDrawPixel` | 10225 | 36220 | 5576 | 6.50x | +| `jlDrawLine H` | 2632 | 9370 | 1322 | 7.09x | +| `jlFillRect 16x16` | 687 | 2414 | 785 | 3.08x | +| `jlFillRect 80x80` | 84 | 306 | 133 | 2.30x | +| `jlFillRect 320x200` | 29 | 103 | 22 | 4.68x | +| `jlDrawCircle r=16` | 165 | 591 | 303 | 1.95x | +| `jlDrawCircle r=80` | 35 | 125 | 70 | 1.79x | +| `jlSpriteDraw` | 2059 | 7397 | 1142 | 6.48x | +| `jlSpriteSaveUnder` | 3570 | 12803 | 602 | 21.27x | +| `jlTileMapPaste 10x5` | 295 | 1055 | 140 | 7.54x | +| `jlStagePresent full` | 392 | 1373 | 73 | 18.81x | +| `gameFrame composite` | 2 | 12 | 12 | 1.00x | +| `gameFrameClean composite` | 2 | 137 | 20 | 6.85x | + +**All 39 shared ops now meet or exceed the Apple IIgs reference; median 7.54x, +slower on none.** The two composite rows are the ones that mattered -- they are +the closest thing UBER has to a real game frame, and they were the reason the +port missed the "within ~1.5x of the native ceiling" directive at 0.17x and +0.10x. `gameFrame composite` is now exactly at parity and `gameFrameClean` is +6.85x. + +The fix was NOT a port of DOS's gExpandLut. DOS fuses two ADJACENT output bytes +into one 16-bit store (mode 13h pixels are neighbouring bytes); here each pixel +is a 16-bit WORD, so its bytes are two apart and that fusion is impossible. +Inverting it works: write whole words, and one 32-bit store covers exactly two +pixels. The high byte is free because the 256-colour index reads only +`$000F | $00F0` (x68k_v.cpp:339-340). Expansion tables indexed by plane and +nibble are pre-interleaved into word positions, so four planes OR together with a +replicated palBase and the inner loop issues four long stores per eight pixels +with no shifting at all -- against roughly 64 operations and eight byte-stores +before. Verified three ways, because each layer misses what the others catch: +51/51 golden (surface correct), a GVRAM dump under PATTERN.X byte-identical to +the pre-rewrite run (pixel encoding correct), and an earlier framebuffer capture +(the display actually renders the bands). + +**Two clock bugs had to be fixed before any of these numbers meant anything, and +every X68000 figure published before 2026-08-06 was wrong.** The port had no +`JL_HAS_MILLIS_ELAPSED` override, so milliseconds derived from the frame counter; +and that counter was POLLED (`vdispPoll`), so an op spanning more than a frame +lost the V-DISP edges in between. The undercount scaled with the op's cost and +cancelled it, making every op report ~10,000 ops/sec regardless of workload -- +`jlFillRect 320x200` (9735) matched `jlFillRect 16x16` (10505) despite 250x the +work. The fix chains Human68k's MFP Timer C, reading its rate from TCDCR/TCDR +rather than assuming it, and derives the frame counter from that tick. +`UBER-CLK` now agrees frame-vs-millisecond on every row, which it could not +before because both figures came from the one broken source. + **2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding #77 (the loader/segment-BSS + malloc-heap reservation that forced the codegen arena into the app's own BSS) was fixed toolchain-side and @@ -285,10 +358,11 @@ glue is Phase 2 Step D, still open). The present-row jumps (ST 467, DOS 1,137) are the dirty-union idle early-out running flag-gated; gameFrame is the honest present signal. -Correctness statement backing this table: all four ports produce +Correctness statement backing this table: all FIVE ports produce BYTE-IDENTICAL pixels on every measured op and every correctness -check -- **49/49** surface hashes match the frozen goldens in -tests/goldens/uber/ (37 timed ops + 12 hashed checks; the PASS/FAIL +check -- **51/51** surface hashes match the frozen goldens in +tests/goldens/uber/ (39 timed ops + 12 hashed checks; the X68000 has no +golden of its own and is diffed straight against iigs.txt; the PASS/FAIL checks -- palette round-trip, PRNG golden sequence, sprite clip round-trip, arena churn, sprite-from-surface -- pass everywhere). The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST @@ -353,10 +427,137 @@ so the per-window Own/Disown it removes was already negligible -- kept for the right blitter discipline, not a speedup. Session present3win arc 106 -> 128 -> 153 -> 154 = +45%. No known remaining flush lever. +**2026-08-05, FIFTH PORT: Sharp X68000 -- the hashes PASS, the clock +does NOT.** src/x68000 + make/x68000.mk (JOEYLIB_PLATFORM_X68000) is on +the golden gate: tools/diff-uber-hashes reports OK: 51/51 ops match. The +port carries NO frozen golden of its own -- it is diffed straight +against tests/goldens/uber/iigs.txt, which it matches exactly. Shape: +10 MHz 68000, NATIVE PLANAR storage (4 x 1bpp planes, 40 bytes/row, +X68K_PLANE_SIZE 8,000), byte-for-byte the Amiga surface layout -- but +the DISPLAY is not the surface. It draws into the TEXT plane (four 1bpp +planes at $E00000/$E20000/$E40000/$E60000, 128-byte stride), NOT +graphics VRAM, so jlpPresent scatters the compact 40-byte rows into that +stride: 32,000 bytes/frame against 128,000 bytes of GVRAM word writes on +the chunky path it replaced (src/x68000/x68kPlanar.h:20-33). The 320x200 +window is CENTRED in a 512x512 mode (_iocs_crtmod 12) at x=96 / y=156, +byte-aligned by construction (96/8 = 12). Palette is the 16-entry +PCG/text palette at $E82200 -- NOT the $E82000 graphic palette -- in +GGGGGRRRRRBBBBBI order, green and red SWAPPED versus RGB555 plus ONE +SHARED intensity LSB; and because the text plane has exactly one +16-entry palette, per-scanline SCB palettes cannot be honoured (palette +0 always wins) -- unlike the ST, which really does emulate per-band SCB +palettes from an MFP Timer B ISR, so this port stands alone in ignoring +the SCB on screen. [SUPERSEDED 2026-08-06: the port moved to the +256-colour graphics plane (_iocs_crtmod 13, palette $E82000) precisely to +fix this -- SCB per-band palettes now work, DOS-style. See the +2026-08-06 entry at the top. The rest of this paragraph still describes +the port accurately.] Sprites COMPILE (shared 68k planar emitter, see the +Reading notes). Toolchain is elf2x68k at toolchains/x68000/m68k-xelf +(gcc 13.4.0, ELF -- so no leading-underscore asm symbols, and +src/m68k/*.s does not link there); disks are built by tools/xdftool.py +(Human68k FAT12, 1024-byte sectors, .X executables); the emulator is a +patched MAME 0.264 single-driver build (patches/mame-0.264-x68k-*.patch). +The only recorded rate-card input so far is from those patches: TVRAM +costs ~1.92 extra cycles/access and GVRAM ~1.03 versus main memory, +applied only at a 10 MHz CPU clock (docs/x68000_mame_crtc_timing.md:307-309, +328) -- which is why the 4x traffic cut to the text plane wins despite +TVRAM's higher per-access cost. NO ceilings are derived for this port +anywhere in this file, and none should be until the clock is fixed. + +THE COLUMN BELOW IS NOT COMPARABLE TO THE OTHER FOUR. It is recorded raw +because the capture is expensive and the log is not kept in-tree, not +because the numbers mean what they say -- see measurement artifact 5. +Capture route: UBER built with the serial log tee +(EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE, which now reaches every port via +make/common.mk:26; src/core/debug.c calls jlpLogTee, implemented at +src/x68000/hal.c:353) against scripts/x68kSerialPeer.py, which MUST be +run as `python3 -u` or a killed peer loses the whole capture to stdio +buffering. This is the only route that gets a full log off the machine: +the disk route (scripts/verify-x68000-golden.sh) forces -DUBER_FRAMES=1u +and cannot produce timings at all -- and in practice cannot produce the +log either, because joeylog.txt never commits its size to the Human68k +image. Run header as reported: total wall time 15,945 ms, 877 frames +@ 55 Hz (both from the suspect clock). + +| Op | X68000 (reported ops/sec) | +| --- | --- | +| jlSurfaceClear | 8775 | +| jlPaletteSet | 14353 | +| jlScbSetRange | 8305 | +| jlDrawPixel | 8580 | +| jlDrawLine H | 8855 | +| jlDrawLine V | 8800 | +| jlDrawLine diag | 8195 | +| jlDrawRect 100x100 | 8470 | +| jlDrawCircle r=16 | 8305 | +| jlDrawCircle r=80 | 7316 | +| jlFillRect 16x16 | 9295 | +| jlFillRect 80x80 | 22182 | +| jlFillRect 320x200 | 6986 | +| jlFillCircle r=40 | 9240 | +| jlSamplePixel | 10395 | +| jlTileFill | 8195 | +| jlTileCopy | 8525 | +| jlTileCopyMasked | 8745 | +| jlTilePaste | 9405 | +| jlTileSnap | 8690 | +| jlSpriteSaveUnder | 8580 | +| jlSpriteDraw | 8250 | +| jlSpriteRestoreUnder | 8886 | +| jlSpriteSaveAndDraw | 8305 | +| jlStagePresent full | 572 | +| jlInputPoll | 8250 | +| jlKeyDown | 53006 | +| jlKeyPressed | 53611 | +| jlMouseX | 125529 | +| joeyJoyConnected | 53006 | +| jlAudioFrameTick | 23756 | +| jlAudioIsPlayingMod | 84472 | +| surfaceMarkDirtyRect (via jlFillRect 32x32) | 8965 | +| jlSpriteDraw unaligned | 8030 | +| jlSpriteDraw sweep16 | 8470 | +| jlTilePaste map 10x5 | 8305 | +| gameFrame composite | 938 | +| jlTileMapPaste 10x5 | 8030 | +| gameFrameClean composite | 1245 | + +The tell is in the table itself, no model needed: 26 of the 39 rows land +inside a single 7,316-9,405 band -- a 1.29x spread across ops whose real +work differs by orders of magnitude. `jlDrawPixel` (8,580), a 100x100 +rect outline (8,470) and a 16x16 sprite draw (8,250) cannot all cost the +same; `jlFillRect 80x80` (22,182) cannot be 2.4x FASTER than `jlFillRect +16x16` (9,295); `jlSurfaceClear` (8,775) cannot land within 1.2x of +`jlSamplePixel` (10,395). The physics check, in the style of artifact 2: +the stage is 4 x 8,000 = 32,000 bytes, so 8,775 clears/sec is 114 +us/clear = ~1,140 cycles at 10 MHz = 28 bytes PER CYCLE. Even at the ST +rate card's write-only fill rate of ~2.2 cyc/B, 32,000 bytes is ~70k +cycles = ~7.0 ms = ~140 ops/sec, so that row reads ~60x high. Same for +the composite: `gameFrame` at 938/s is 1.07 ms = ~10,700 cycles for a +256x96 fill + 16 tile pastes + 3 saveAndDraw + 3 restores + a 96-row +band present, when the fill ALONE is 12,288 B x 2.2 = 27k cycles (the +very figure this file uses for the ST gameFrame ceiling) -- 2.5x the +whole measured frame. Only the cheap rows survive: at ~8 us/call +`jlMouseX` (125,529), `jlKeyDown` (53,006) and `jlAudioIsPlayingMod` +(84,472) poll the clock far more often than once per frame and read +honestly. + +For calibration once the clock is fixed, this port sits between the two +68k ports already in the table: a 10 MHz 68000 with NO blitter -- the +ST's situation with 25% more clock -- writing a compact 4-plane surface +in plain malloc'd main RAM (no Chip RAM constraint, nothing but the CPU +ever reads it) and then scattering 32,000 bytes/frame into TVRAM at its +~1.92-cycle access penalty. So CPU-bound rows should be expected ABOVE +the 8.0 MHz ST roughly on clock ratio, while the present should land +between the ST (bus-floor CPU copy, 8 MHz) and the Amiga (7.09 MHz but a +blitter and display-DMA contention). None of that is measured yet, and +none of it can be until artifact 5 is closed. + ## Measurement honesty (read before trusting any number) -Four measurement artifacts have shaped this table's history. Three are -fixed, one is inherent -- know them before comparing rows: +Five measurement artifacts have shaped this table's history. Three are +fixed, one is inherent, and one -- the X68000 frame clock -- is OPEN and +invalidates that port's entire ops/sec column: know them before comparing +rows: 1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench loop called jlFrameCount() between every op; on the IIgs that was a @@ -383,9 +584,12 @@ fixed, one is inherent -- know them before comparing rows: Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22% faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz), confirming it was ST-only. Two fixes landed: (a) UBER now computes - ops/sec from `jlMillisElapsed` (refresh-independent on all four - ports; identical to the old formula on IIgs where millis is - GetTick-derived), and (b) ST `jlpFrameHz()` now reads the shifter + ops/sec from `jlMillisElapsed` (its own hardware clock on the ST and + DOS; derived from the frame counter on the IIgs -- identical to the + old formula -- and on the Amiga and X68000, neither of which + overrides jlpMillisElapsed; on the X68000 that counter is POLLED, so + both UBER clocks carry the same error and the frame-vs-millis + cross-check cannot see it -- artifact 5), and (b) ST `jlpFrameHz()` now reads the shifter resolution + sync-mode registers to return the true 50/60/71 Hz. The per-op `UBER-CLK:` log line reports frame-vs-millis as a standing cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw @@ -400,6 +604,31 @@ fixed, one is inherent -- know them before comparing rows: gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug class (~1.8% / ~0.5% / 0% idle cost). +5. **X68000 polled frame clock -- OPEN; that port's whole ops/sec + column is invalid.** _iocs_vdispst HANGS the machine when handed a + plain C handler, so the V-DISP interrupt is never installed + (src/x68000/hal.c:85-92; gVdispInstalled stays false) and + jlpFrameCount instead edge-detects the MFP GPIP V-DISP bit each time + something calls it (hal.c:310-323). A polled edge detector loses + ticks exactly when the work is heavy, and the HAL says so in its own + comment: "Measured: 400 audio refills across ~19 frames reported ZERO + elapsed frames, because nothing polled in between" (hal.c:275-279). + UBER polls once per 16-op batch (uber.c:201-219), so any op whose + batch stretches past a V-DISP period makes the counter under-count, + and ops/sec = iters * 1000 / millis over-reports by that same factor. + The frame-vs-millis cross-check built for artifact 3 cannot catch it: + the X68000 has no jlpMillisElapsed override, so millis comes from + jlpGenericMillisElapsed, which is computed FROM jlpFrameCount + (src/core/port.h:753-758, src/generic/genericPort.c:52-97) -- every + UBER-CLK line on that port compares a number with itself, which is + why they all agree to within 0.5%. Cheap rows (jlMouseX, jlKeyDown, + jlAudioIsPlayingMod) poll often enough to catch every edge and read + honestly; everything else is inflated, ~60x on the full-screen rows. + THE FIX is recorded in the HAL: an asm thunk for _iocs_vdispst (save + d0-d1/a0-a1, call, restore, correct return) so the interrupt owns the + counter (hal.c:87-91, 298-300). Until that lands the X68000 gates on + HASHES ONLY -- it is a correctness column, not a performance column. + ## Baseline (2026-07-08): absolute ops/sec per port The IIgs is the reference and the perf floor (every other port must @@ -407,7 +636,10 @@ meet or beat it -- project directive). Non-IIgs cells show absolute ops/sec plus percentage of the IIgs number; sub-100% cells are bolded as below-floor. `tools/uber-perf-table` emits this table directly; regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the -default cuts off the final checks). Capture provenance: all four +default cuts off the final checks). The tool still knows only the +original four ports (its PORTS list, tools/uber-perf-table:40-45) and +there is no bench-x68000.sh, so an X68000 column has to be added by +hand from a serial-tee capture -- see the 2026-08-05 entry. Capture provenance: all four ports 2026-07-08 from a full `make clean` rebuild (Phase 6 W1+W2+W3 landed: fused IIgs sprite/fillRect entries, sprite-mark specialization, per-row/fused presents on Amiga/ST/IIgs, ST movep @@ -798,13 +1030,19 @@ the real-HW column is the datasheet claim. ## Reading notes -* **Sprite rows measure COMPILED code on all four ports** since +* **Sprite rows measure COMPILED code on all five ports** since NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw variants plus group-window save/restore (previously stubs that fell to interpreters), IIgs codegen re-enabled via finding #77, DOS via - finding #74. The interpreted walkers remain only as arena-full + finding #74. The X68000 joined on the shared 68k PLANAR emitter with + no emitter changes -- it takes the AMIGA arm of + src/core/spriteDispatch.h (4-plane cdecl routines from + src/m68k/spriteEmitPlanar68k.c, one pre-shifted variant per x % 8 + phase, all 8 compiled), because its surface stride is the Amiga's 40 + bytes and the display's 128-byte text-plane stride never reaches the + emitter. The interpreted walkers remain only as arena-full degrade paths -- and are proven bit-identical to the compiled code - by the 49-hash gate. + by the 51-hash gate. * **The below-floor cells are the standing punch list**, and after the 2026-07-07 audio-tax fix the ST has NONE -- every remaining below-floor graphics cell is the Amiga's: the circle family (diag @@ -833,7 +1071,7 @@ the real-HW column is the datasheet claim. call on the 65816; 32-bit shifts there are per-bit helper loops). Output is bit-identical -- host-proven over the golden sequence and a 50M-state sweep, and gated by UBER's random-golden check on all - four ports. + five ports. * **Input predicates stay functions** (Phase 8 #40 decision): the post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere near a real frame budget; macro forms in the public header were diff --git a/README.md b/README.md index a43970f..67294f5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # JoeyLib -A unified C game-development library targeting four early 16-bit +A unified C game-development library targeting five early 16-bit platforms from a single codebase: - Apple IIgs (reference platform) - Commodore Amiga (A500 / 68000 baseline) - Atari ST (STF / 68000 baseline) - MS-DOS (386 / VGA, DJGPP) +- Sharp X68000 (68000 @ 10 MHz, Human68k, elf2x68k) The Apple IIgs defines the capability ceiling. Stronger platforms coast. Hot paths are hand-written assembly per port; the public API is C. @@ -26,8 +27,12 @@ make ``` This builds `libjoey.a` for every target whose toolchain is installed, -plus the example programs (`hello`, `pattern`, `keys`, `serial`, `save`, -`joy`, `sprite`, `audio`) for each. +plus the example programs (`hello`, `pattern`, `draw`, `keys`, `serial`, +`sertest`, `save`, `joy`, `sprite`, `audio`, `uber`, `adventure`, +`adventure2`, `agi`, `spacetaxi`) for each. The IIgs is the exception: +`make iigs` compile-checks the library and runs the clang smoke test, and +`make -f make/iigs.mk iigs-examples` builds its example binaries. The +Sharp X68000 is not covered at all -- see below. ## Building for a single target @@ -40,6 +45,43 @@ make atarist make dos ``` +The Sharp X68000 has no top-level target: `toolchains/install.sh` does not +fetch elf2x68k and the root `Makefile` has no `x68000` rule. Stage the +elf2x68k tarball under `toolchains/x68000/m68k-xelf` and drive the fragment +directly -- it resolves its own compiler relative to the repo, so this one +does not need `env.sh`: + +``` +make -f make/x68000.mk # libjoey.a plus SERIAL.X, UBER.X, AUDIO.X +``` + +Every fragment also answers to a bare `clean`, and every fragment that builds +through `make/common.mk` -- all of them except `make/iigs.mk`, which assembles +its own clang flags -- honors `EXTRA_CFLAGS`: + +``` +make -f make/atarist.mk clean +make -f make/x68000.mk EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE +``` + + +## Conformance gate + +`examples/uber` is the conformance vehicle: it exercises every public op and +logs a surface hash per op. `tools/diff-uber-hashes ` +compares two `joeylog.txt` captures and exits non-zero on any mismatch. The +frozen references live in `tests/goldens/uber/`. All five ports currently agree +on all 51 hashes; the X68000 has no golden of its own and is diffed against +`tests/goldens/uber/iigs.txt`, which it matches exactly: + +``` +make -f make/x68000.mk x68000-verify-golden # ~70 min under the patched MAME +make -f make/x68000.mk x68000-verify-serial # both-directions RS-232C, ~1 min +``` + +Both X68000 gates want `X68K_SCRATCH` pointing at a work dir containing +`x68mame/`, and a MAME built with the patches in `patches/`. + ## Repository layout @@ -47,12 +89,24 @@ make dos docs/ design and reference documentation include/joey/ public headers src/core/ portable library code -src/codegen/ runtime sprite codegen (per-CPU emitters) -src/port// per-platform HAL implementations -src/shared68k/ assembly shared by Amiga and Atari ST +src/generic/ portable-C default for every overridable op +src/codegen/ shared sprite-codegen staging / compile machinery +src// per-machine HAL + per-CPU sprite emitter + (iigs, amiga, atarist, dos, x68000) +src/m68k/ 68k code shared across machines (planar and word- + interleaved sprite emitters; surface68k.s, which is + linked by Amiga and Atari ST only) +src/blank/ copy-to-start template for a new port tools/assetbake/ PNG -> native .tbk / .spr baker (Python) tools/joeymod/ Protracker .MOD converter (passthrough or .NTP) +tools/spritebake/ offline .spr -> .spc pre-compiled sprite baker +tools/songbake/ chip-tracker text notation -> JYM1 stream +tools/xdftool.py Human68k FAT12 .XDF disk image reader / writer +tools/diff-uber-hashes UBER golden-hash comparator examples/ example programs +scripts/ run / bench / verification scripts +patches/ MAME patches the X68000 gates need +tests/goldens/uber/ frozen UBER conformance hashes toolchains/ self-contained cross-build tools make/ per-target Makefile fragments build// per-target build outputs @@ -77,8 +131,7 @@ is a quick reference. Every entry point is plain C, no C++ extensions. ```c typedef struct { uint32_t codegenBytes; // runtime compiled-sprite cache size - uint16_t maxSurfaces; // maximum concurrent surfaces - uint32_t audioBytes; // audio sample / module RAM pool + uint32_t audioBytes; // reserved; not yet consulted by any engine } jlConfigT; bool jlInit (const jlConfigT *config); @@ -87,9 +140,17 @@ const char *jlLastError (void); const char *jlPlatformName (void); const char *jlVersionString(void); +void *jlAlloc (uint32_t bytes); // native allocator, not malloc +void jlFree (void *p); + void jlWaitVBL (void); // block until next VBL uint16_t jlFrameCount (void); // monotonic 16-bit frame counter -uint16_t jlFrameHz (void); // 50 / 60 / 70 depending on port +uint16_t jlFrameHz (void); // 50 / 55 / 60 / 70 depending on port +uint32_t jlMillisElapsed(void); // monotonic ms since jlInit + +uint32_t jlRandom (void); // portable, bit-identical per seed +uint16_t jlRandomRange (uint16_t bound); +void jlRandomSeed (uint32_t seed); ``` @@ -98,9 +159,9 @@ uint16_t jlFrameHz (void); // 50 / 60 / 70 depending on port All surfaces are 320x200 16-color images with a 200-entry SCB table and 16 palettes of 16 `$0RGB` colors. In-memory storage is target-native: chunky 4bpp packed on IIgs and DOS, native planar -(separate bitplanes on Amiga, word-interleaved planes on Atari ST) -on the 68k ports. The public API speaks in color indices (0..15) and -hides the storage format. +(separate bitplanes on Amiga and Sharp X68000, word-interleaved planes +on Atari ST) on the 68k ports. The public API speaks in color indices +(0..15) and hides the storage format. ```c #define SURFACE_WIDTH 320 @@ -159,7 +220,11 @@ void jlFloodFillBounded (jlSurfaceT *s, int16_t x, int16_t y, Colors are 12-bit `$0RGB`. Color 0 of every palette is forced to black on `jlPaletteSet`. Each scanline picks one of the 16 palettes -via the SCB. +via the SCB. Every port honours it. The X68000 does it the same way +the DOS port does: it renders through the 256-colour graphics plane, +so all 16 palettes are resident at once and each pixel byte is +`(scb[y] << 4) | nibble` -- the pixel value selects the band, with no +raster interrupt involved. ```c void jlPaletteSet (jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16); @@ -244,8 +309,18 @@ void jlSpriteDestroy (jlSpriteT *sp); uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCels, uint16_t *outPalette); +// Same contract, but reading a `.spc` bank pre-compiled for THIS target by +// the offline baker `tools/spritebake`: the native blit routines are copied +// straight into the codegen arena, so there is no startup JIT. The runtime +// twin writes one back out after a jlSpriteCompile pass. +uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, + uint16_t maxCels, uint16_t *outPalette); +bool jlSpriteBankSavePrecompiled(const char *path, jlSpriteT **cels, + uint16_t count, const uint16_t *palette); + bool jlSpriteCompile (jlSpriteT *sp); // build per-shift fast path void jlSpritePrewarm (jlSpriteT *sp); // hint: compile if not already +uint32_t jlSpriteCompiledSize (const jlSpriteT *sp); void jlSpriteDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); void jlSpriteSaveUnder (const jlSurfaceT *s, jlSpriteT *sp, @@ -254,6 +329,17 @@ void jlSpriteRestoreUnder (jlSurfaceT *s, const jlSpriteBackupT *backup void jlSpriteSaveAndDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup); +// TRUSTED variants: skip the per-call geometry validation for a caller that +// pre-compiles its sprites and guarantees the sprite is FULLY on-surface. +// UB if that is violated -- no clipping happens. They fall back to the +// validated entry for uncompiled sprites and on ports with no compiled path. +void jlSpriteDrawTrusted (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); +void jlSpriteSaveUnderTrusted (const jlSurfaceT *s, jlSpriteT *sp, + int16_t x, int16_t y, jlSpriteBackupT *backup); +void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup); +void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, + jlSpriteBackupT *backup); + void jlSpriteCompact (void); // defrag the codegen arena uint32_t jlSpriteCodegenBytesUsed (void); uint32_t jlSpriteCodegenBytesTotal (void); @@ -273,7 +359,11 @@ Two blob formats: * **`.tbk` (tile bank)** -- one or more 8x8 tiles in per-target planar layout (Amiga plane-major; Atari ST row-major-with-planes- per-row; DOS / IIgs chunky 4bpp). The loader `jlTileBankLoad` - rejects files baked for the wrong target. + rejects files baked for the wrong target. There is no X68000 target: + `assetbake.py` bakes only the four above, and the loader still expects + the DOS chunky target byte there (`src/core/assetLoad.c`) even though + that port's surfaces and tile ops are now planar -- so `.tbk` baking + is not wired up for the X68000 yet. * **`.spr` (sprite cel set)** -- one or more uniform-sized sprite cels in cross-target chunky 4bpp. The Phase 11 walker reads chunky and converts to planar at draw time, so the same blob serves every @@ -286,10 +376,13 @@ tools/assetbake/assetbake.py --type tile --target {amiga|atarist|dos|iigs} in. tools/assetbake/assetbake.py --type sprite --cell WxH in.png out.spr ``` -The Makefiles (`make/{amiga,atarist,dos,iigs}.mk`) wire bake rules +The Makefiles (`make/{amiga,atarist,dos}.mk`) wire bake rules per target so `make ` produces baked blobs under `examples//generated//` and stages them into the -runtime tree at `build//.../DATA/`. +runtime tree at `build//.../DATA/`. `make/iigs.mk` runs no +`assetbake.py`: its `generated/iigs` tree is produced out-of-band and the +fragment only bakes `.spc` from the committed `.spr`. `make/x68000.mk` +has no asset rules at all. For runtime-extracted content (sprites peeled out of a procedural or captured surface), use `jlSpriteCreateFromSurface` -- the @@ -351,14 +444,18 @@ void jlJoystickReset (jlJoystickE js, uint8_t deadZone); ### Audio (`joey/audio.h`) -4-channel Protracker-style music plus four one-shot SFX slots. Module -data must be the platform-native form produced by `tools/joeymod` -(`.mod` for Amiga/DOS/ST; `.ntp` for IIgs; `.amod` if you want -loop=false on Amiga). A failed `jlAudioInit` is non-fatal; the rest -of the API stays callable as no-ops. +4-channel Protracker-style music plus five one-shot SFX slots, three +PSG-style tone voices and a noise channel. Module data must be the +platform-native form produced by `tools/joeymod` (`.mod` for +Amiga/DOS/ST/X68000; `.ntp` for IIgs; `.amod` if you want loop=false on +Amiga). Amiga plays `.mod` through PTPlayer and the IIgs through the +NinjaTrackerPlus replayer; DOS, Atari ST and X68000 decode with +libxmp-lite. A failed `jlAudioInit` is non-fatal; the rest of the API +stays callable as no-ops. ```c -#define JOEY_AUDIO_SFX_SLOTS 4 +#define JOEY_AUDIO_SFX_SLOTS 5 +#define JOEY_AUDIO_VOICES 3 bool jlAudioInit (void); void jlAudioShutdown (void); @@ -369,8 +466,24 @@ bool jlAudioIsPlayingMod (void); void jlAudioPlaySfx (uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz); +void jlAudioPlaySfxStream (uint8_t slot, jlAudioStreamFillT fill, void *ctx, + uint16_t rateHz); void jlAudioStopSfx (uint8_t slot); +// Portable chip-tracker music: a JYM1 event stream baked by tools/songbake, +// played through the tone/noise layer at register-write cost on every port. +// No CPU mixing, so it is viable where jlAudioPlayMod is not. +bool jlMusicPlay (const uint8_t *data, uint32_t length, bool loop); +void jlMusicStop (void); +bool jlMusicIsPlaying (void); +void jlMusicPause (void); +void jlMusicResume (void); +void jlMusicSetAtten (uint8_t atten); + +void jlAudioTone (uint16_t freqHz); +void jlAudioVoice (uint8_t voice, uint16_t freqHz, uint8_t atten); +void jlAudioNoise (uint8_t pitch, uint8_t atten); + void jlAudioFrameTick (void); ``` @@ -418,12 +531,12 @@ void jlSerialFlush (void); // discard buffered input Per-port device mapping: -| Device | IIgs | DOS | Amiga | Atari ST | -|---------------------|-------------------------|-----------|-------------------|--------------| -| `JL_SERIAL_DEFAULT` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | -| `JL_SERIAL_MODEM` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | -| `JL_SERIAL_PRINTER` | printer port (SCC B) | COM1 | serial.device 0 | Modem/RS-232 | -| `JL_SERIAL_SLOT` | 6551 SSC in slot `unit` | COM`unit` | serial.device `unit` | Modem/RS-232 | +| Device | IIgs | DOS | Amiga | Atari ST | X68000 | +|---------------------|-------------------------|-----------|-------------------|--------------|-----------------| +| `JL_SERIAL_DEFAULT` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) | +| `JL_SERIAL_MODEM` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) | +| `JL_SERIAL_PRINTER` | printer port (SCC B) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) | +| `JL_SERIAL_SLOT` | 6551 SSC in slot `unit` | COM`unit` | serial.device `unit` | Modem/RS-232 | RS-232C (SCC A) | Only the IIgs has multiple built-in ports; elsewhere `MODEM`/`PRINTER` fall back to the natural port. The `serial` example is an echo terminal and @@ -456,7 +569,8 @@ uint32_t jlDiskFree (void); // free bytes on the save volume (0 if unknown) Directory create / delete / free-space use a per-port HAL (POSIX `mkdir` + `remove` + INT 21h on DOS; `mkdir` + GEMDOS `Dfree` on ST; dos.library `CreateDir`/`DeleteFile`/`Info` on Amiga; GS/OS `Create` + a -`GetDevNumber`/`DInfo`/`Volume` chain on the IIgs). `scripts/check-save.sh` +`GetDevNumber`/`DInfo`/`Volume` chain on the IIgs; Human68k `_dos_mkdir` + +`remove` + `_dos_dskfre` on the X68000). `scripts/check-save.sh` round-trips the core against real files; `make iigs-verify-save` boots the IIgs save path under MAME. Full reference, per-port behavior, and porting notes: [`docs/save.md`](docs/save.md). @@ -477,6 +591,15 @@ void jlLogReset(void); Output goes to `joeylog.txt` in the program's working directory. +Building with `EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE` additionally mirrors every +`jlLog` / `jlLogF` line out the port's serial line as it is written -- +unbounded, needs no disk, and arrives in real time, which is what makes a +wedged run diagnosable. It is OFF by default and is currently implemented +only for the X68000 (`jlpLogTee` in `src/x68000/hal.c`); the host end is +`scripts/x68kSerialPeer.py`, which must be run under `python3 -u` -- it never +passes `flush=True`, so a killed peer loses its whole capture to stdio +buffering. + ### Platform macros (`joey/platform.h`) diff --git a/include/joey/platform.h b/include/joey/platform.h index 5fa84b0..c6250de 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -436,6 +436,10 @@ #define JL_HAS_WAIT_VBL // _iocs_vsync #define JL_HAS_FRAME_COUNT #define JL_HAS_FRAME_HZ // ~55 Hz in the 31 kHz modes + // Chains Human68k's MFP Timer C (~100 Hz). Without this the port fell back to + // the generic clock, which derives ms from the POLLED frame counter and + // undercounts exactly in proportion to how slow the op is. + #define JL_HAS_MILLIS_ELAPSED // Save add-on: Human68k _dos_dskfre / _dos_mkdir, structurally the ST HAL. #define JL_HAS_DISK_FREE #define JL_HAS_SAVE_DELETE diff --git a/make/x68000.mk b/make/x68000.mk index 991a415..b19d9b6 100644 --- a/make/x68000.mk +++ b/make/x68000.mk @@ -37,10 +37,10 @@ CFLAGS := $(COMMON_CFLAGS) -m68000 -fomit-frame-pointer \ LDFLAGS := -lm PORT_C_SRCS := $(wildcard $(SRC_DIR)/x68000/*.c) -# Shared 68k planar asm (src/m68k/surface68k.s), already used by Amiga and ST: -# circle outline and 4-plane span fill. Free reuse now the surface layout -# matches. -SHARED_S := $(wildcard $(SRC_68K)/*.s) +# Port-local 68000 asm, if any lands. Empty today: SCB per-band palettes are +# done in the pixel expansion (DOS-style), not with a raster ISR, so the port is +# pure C again. +PORT_S_SRCS := $(wildcard $(SRC_DIR)/x68000/*.s) # No src/m68k sources yet: those are the planar sprite emitters and the planar # surface helpers, and this port is still chunky. They come in with the storage @@ -63,6 +63,7 @@ LIB_OBJS := \ $(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS)) \ $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(patsubst $(SRC_DIR)/x68000/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ + $(patsubst $(SRC_DIR)/x68000/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ $(BUILD)/obj/68k/spriteEmitPlanar68k.o \ $(BUILD)/obj/codegen/spriteCompile.o \ $(BUILD)/obj/codegen/spriteStage.o @@ -89,6 +90,7 @@ $(LIBXMP_AR): $(LIBXMP_OBJS) LIB := $(LIBDIR)/libjoey.a +PATTERN_SRC := $(EXAMPLES)/pattern/pattern.c SERIAL_SRC := $(EXAMPLES)/serial/serial.c UBER_SRC := $(EXAMPLES)/uber/uber.c AUDIO_SRC := $(EXAMPLES)/audio/audio.c @@ -99,7 +101,7 @@ all x68000: x68000-lib x68000-examples x68000-lib: $(LIB) $(LIBXMP_AR) -x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X +x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X $(BINDIR)/PATTERN.X $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -109,6 +111,12 @@ $(BUILD)/obj/generic/%.o: $(SRC_DIR)/generic/%.c @mkdir -p $(dir $@) $(X68K_CC) $(CFLAGS) -c $< -o $@ +# Port-local asm. Same object dir as the port C objects; make picks between the +# two pattern rules by which prerequisite exists. +$(BUILD)/obj/port/%.o: $(SRC_DIR)/x68000/%.s + @mkdir -p $(dir $@) + $(X68K_CC) $(CFLAGS) -c $< -o $@ + $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s @mkdir -p $(dir $@) $(X68K_CC) $(CFLAGS) -c $< -o $@ @@ -129,6 +137,12 @@ $(LIB): $(LIB_OBJS) @mkdir -p $(dir $@) $(X68K_AR) rcs $@ $(LIB_OBJS) +# PATTERN is the SCB acceptance vehicle: 8 bands of 25 lines with 8 gradient +# palettes, so a mis-placed band boundary is immediately visible. +$(BINDIR)/PATTERN.X: $(PATTERN_SRC) $(LIB) $(LIBXMP_AR) + @mkdir -p $(dir $@) + $(X68K_CC) $(CFLAGS) $(PATTERN_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@ + $(BINDIR)/SERIAL.X: $(SERIAL_SRC) $(LIB) $(LIBXMP_AR) @mkdir -p $(dir $@) $(X68K_CC) $(CFLAGS) $(SERIAL_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@ diff --git a/src/x68000/hal.c b/src/x68000/hal.c index c38760d..6232020 100644 --- a/src/x68000/hal.c +++ b/src/x68000/hal.c @@ -20,6 +20,9 @@ #include #include "port.h" +#include + +#include "joey/debug.h" #include #include "surfaceInternal.h" @@ -34,10 +37,11 @@ // width, so the row stride is a constant, not a function of SURFACE_WIDTH. #define X68K_GVRAM_STRIDE 512u -// _iocs_crtmod screen mode. 12 = 512x512, 16 colours, 31 kHz. The library's -// 320x200 stage is drawn into the top-left corner; there is no 320x200 mode on -// this machine, so some form of letterboxing is unavoidable. -#define X68K_CRTMOD_512_16 12 +// _iocs_crtmod screen mode. 13 = 512x512, 256 colours, 31 kHz -- same timing as +// mode 12 (512x512/16) one slot below it, but with the 256-entry graphics +// palette this port needs for SCB per-band palettes. There is no 320x200 mode on +// this machine, so the stage is centred and letterboxed. +#define X68K_CRTMOD_512_256 13 // _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about. #define X68K_KEYGROUP_COUNT 15 @@ -49,6 +53,57 @@ #define X68K_TEE_SPIN_LIMIT 200000ul +// ----- SCB per-band palette (256-colour graphics plane) ---------------------- +// +// JoeyLib surfaces carry an SCB: one palette index per scanline. This port does +// it the way the DOS port does (src/dos/hal.c:7), not the way the Atari ST does. +// DOS writes `pixel byte = (scb[y] << 4) | nibble` into a 256-entry DAC, so all +// 16 palettes are resident at once and the PIXEL VALUE selects the band. No +// interrupt, no timing window, correct by construction. +// +// The ST needs a raster interrupt only because the STF shifter really has 16 +// hardware colours. This machine does not: the graphics plane in 256-colour mode +// has a 256-entry palette at $E82000 -- exactly 16 palettes x 16 colours. The +// TEXT plane cannot do it (four 1bpp planes cap it at 16, and MAME's +// get_text_pixel resolves 0-15 only), which is why this port moved off it. +// +// GVRAM layout in 256-colour mode, from MAME x68k_v.cpp:339-340: +// colour = gvram[lineoffset0 + x] & 0x000F; // low nibble +// colour |= gvram[lineoffset1 + x] & 0x00F0; // high nibble +// With both graphics scroll registers 0 the two line offsets are equal, so both +// nibbles come from the SAME word: the palette index is that word's LOW BYTE. +// One byte write per pixel, 512 words per line. +#define X68K_GVRAM_BASE 0x00C00000UL +#define X68K_GVRAM_WORDS_ROW 512u +// 256-entry graphics palette (x68k.cpp:965). NOT $E82200, the 16-entry text/PCG +// block the old text-plane path used. +#define X68K_GFX_PALETTE ((volatile uint16_t *)0xE82000L) +#define X68K_PALETTE_ENTRIES (SURFACE_PALETTE_COUNT * SURFACE_COLORS_PER_PALETTE) + + +// ----- Millisecond clock ----------------------------------------------------- +// +// Without this the port fell back to jlpGenericMillisElapsed(), which derives +// milliseconds from the FRAME COUNTER -- and this port's counter is polled +// (vdispPoll), not interrupt-driven. An op spanning more than one frame lets +// V-DISP edges pass unobserved, so the measured time shrank in proportion to how +// slow the op was and the two errors cancelled: every UBER row landed near +// 10,000 ops/sec regardless of workload. Games pacing on jlMillisElapsed were +// equally wrong. +// +// Fix: chain Human68k's existing MFP Timer C tick (~100 Hz) and count it. MFP +// channel 5, so vector ($40 | 5) = $45 at address $45 * 4 = $114. +#define X68K_TICK_VEC_ADDR 0x00000114ul +// Timer C's rate is NOT assumed -- it is derived at init from the MFP's own +// registers, because Human68k's programming is not what you would guess. A +// measured run showed ~178 Hz, not the 100 Hz a first guess suggested: +// 4 MHz / (prescale 200 * TCDR 112) = 178.6 Hz. +#define X68K_MFP_TCDCR ((volatile uint8_t *)0xE8801DL) // reg 14 +#define X68K_MFP_TCDR ((volatile uint8_t *)0xE88023L) // reg 17 +#define X68K_MFP_CLOCK_HZ 4000000ul +#define X68K_TCDCR_C_SHIFT 4u +#define X68K_TCDCR_C_MASK 0x07u + // ----- Module state --------------------------------------------------------- static int gPrevCrtMode = -1; @@ -62,10 +117,30 @@ static uint8_t gLastVdisp = 0; // True once the V-DISP interrupt handler is live; the poll then stands down. static bool gVdispInstalled = false; +// Bumped by x68kTickIsr.s on every Human68k Timer C interrupt. Written from +// interrupt context, so volatile; read with a single long load, which is atomic +// against the handler's addq.l on a 68000. +volatile uint32_t gX68kTicks = 0ul; +extern void x68kTickIsr(void); +extern uint32_t x68kTickChainAddr; // the JMP operand inside the ISR +static uint32_t gTickBase = 0ul; +static uint32_t gTickHz = 0ul; +static bool gTickBaseSet = false; +static bool gTickHooked = false; + +// ----- Prototypes ----- + +static uint32_t tickHzFromMfp(void); +uint32_t jlpMillisElapsed(void); +uint16_t jlpFrameHz(void); +static void buildSpreadTables(void); +static void installTickIsr(void); + +static void uploadGfxPalette(const jlSurfaceT *src); -// Defined below with the timing code; declared here because jlpInit installs it. static void vdispHandler(void); static void vdispPoll(void); + extern void *const gX68kVdispHandlerRef; @@ -80,16 +155,19 @@ bool jlpInit(const jlConfigT *config) { // if we were already supervisor -- in which case do not switch back. gPrevSsp = _dos_super(0); gPrevCrtMode = _iocs_crtmod(-1); // -1 queries without changing - _iocs_crtmod(X68K_CRTMOD_512_16); + _iocs_crtmod(X68K_CRTMOD_512_256); _iocs_g_clr_on(); // clear graphics + enable the plane // NOTE: installing vdispHandler via _iocs_vdispst HANGS the machine -- // tested, no serial output at all, so it wedges before main() gets going. - // The handler ABI is not a plain C function: a _VDISPST handler is entered - // from the interrupt and this one returns with RTS from a context IOCS does - // not expect. Getting it right needs an asm thunk (save d0-d1/a0-a1, call, - // restore, correct return), which is worth doing but is not a one-liner. + // IOCS does NOT wrap the handler: _VDISPST writes the pointer straight into + // exception vector $134 (iplrom.dat $FF9DC8), exactly as _CRTCRAS does for + // $138. So the handler must be a raw interrupt routine -- preserve every + // register, return with RTE -- and a C function (RTS, free to clobber + // d0/d1/a0/a1) can never be valid. A raw asm handler would be required; + // the polled fallback below owns the frame counter until then. // Until then the polled fallback below owns the counter. gVdispInstalled = false; + installTickIsr(); gModeSet = true; return true; } @@ -110,6 +188,120 @@ void jlpShutdown(void) { } +// Timer C's interrupt rate, read from the MFP rather than assumed: +// MFP clock / (prescaler * data register). The prescaler is a 3-bit code in the +// high nibble of TCDCR; code 0 means the timer is stopped. +static uint32_t tickHzFromMfp(void) { + static const uint16_t kPrescale[8] = { 0u, 4u, 10u, 16u, 50u, 64u, 100u, 200u }; + uint16_t code; + uint16_t divisor; + uint16_t data; + + code = (uint16_t)((*X68K_MFP_TCDCR >> X68K_TCDCR_C_SHIFT) & X68K_TCDCR_C_MASK); + divisor = kPrescale[code]; + if (divisor == 0u) { + return 0ul; + } + data = *X68K_MFP_TCDR; + if (data == 0u) { + data = 256u; // 0 in the data register means 256 + } + return X68K_MFP_CLOCK_HZ / ((uint32_t)divisor * (uint32_t)data); +} + + +// Chain our counter onto Human68k's Timer C interrupt. The previous handler is +// patched into the ISR's JMP operand, so it still runs and still owns the RTE -- +// the OS clock keeps working. Supervisor already (jlpInit's _dos_super), so the +// vector write is legal. Refuses if the vector looks unset, because chaining to +// nothing would fault on the first tick. +static void installTickIsr(void) { + // Laundered through a volatile: gcc treats a constant pointer this close to + // zero as a null dereference and -Werror=array-bounds rejects it, though + // $114 is a perfectly real supervisor-writable exception vector. + static volatile uintptr_t addr = X68K_TICK_VEC_ADDR; + volatile uint32_t *vec; + + vec = (volatile uint32_t *)addr; + if (*vec == 0ul) { + return; + } + gTickHz = tickHzFromMfp(); + if (gTickHz == 0ul) { + return; // timer stopped: no usable clock + } + x68kTickChainAddr = *vec; + *vec = (uint32_t)x68kTickIsr; + gTickHooked = true; +} + + +// ----- SCB per-band palette -------------------------------------------------- + +// Load all 16 palettes into the 256-entry graphics palette: entry p*16+c is +// palette p colour c, so a pixel byte of (p << 4) | c selects it directly. +// Rebuilt only when a palette actually changes; the SCB flag is deliberately +// NOT consumed here, because the per-line choice is applied in the pixel +// expansion, which reads src->scb directly. Same contract as +// src/dos/hal.c:232-234. +static void uploadGfxPalette(const jlSurfaceT *src) { + uint16_t pal; + uint16_t col; + + for (pal = 0u; pal < SURFACE_PALETTE_COUNT; pal++) { + for (col = 0u; col < SURFACE_COLORS_PER_PALETTE; col++) { + X68K_GFX_PALETTE[(pal << 4) | col] = x68kColorFromRgb12(src->palette[pal][col]); + } + } +} + + +// ----- Present expansion tables ---------------------------------------------- +// +// The naive loop extracted one nibble per pixel with four shifts and a mask, +// then stored one byte: about 8 operations per pixel over 64,000 pixels, which +// put the composite game frame at 0.10x of the IIgs. +// +// DOS's gExpandLut cannot be copied directly: mode 13h pixels are ADJACENT +// bytes so DOS fuses two into one 16-bit store, whereas GVRAM here is one +// 16-bit WORD per pixel (index in the low byte), so the output bytes are two +// apart. The fusion that works on this machine is the other way up -- write +// WHOLE WORDS, because one 32-bit store then covers exactly two pixels. The +// high byte belongs to graphics pages 2/3, which the 256-colour index does not +// read (x68k_v.cpp:339-340 masks $000F | $00F0), so writing 0 there is safe. +// +// So the tables are indexed by a PLANE and a NIBBLE (4 source pixels) and are +// pre-interleaved into word positions: gSpreadHi holds the first two of those +// pixels as (px0 << 16) | px1, gSpreadLo the second two. Four planes OR +// together, palBase ORs in replicated, and the result is two long stores per +// four pixels -- no shifting in the inner loop at all. +#define X68K_SPREAD_NIBBLES 16u + +static uint32_t gSpreadHi[X68K_BITPLANES][X68K_SPREAD_NIBBLES]; +static uint32_t gSpreadLo[X68K_BITPLANES][X68K_SPREAD_NIBBLES]; +static bool gSpreadBuilt = false; + + +// Bit 3 of the nibble is the LEFTMOST of its four pixels, matching the plane +// byte convention (bit 7 leftmost of eight). +static void buildSpreadTables(void) { + uint16_t plane; + uint16_t nib; + uint32_t bit; + + for (plane = 0u; plane < X68K_BITPLANES; plane++) { + bit = (uint32_t)1ul << plane; + for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) { + gSpreadHi[plane][nib] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul) + | (((nib >> 2) & 1u) ? bit : 0ul); + gSpreadLo[plane][nib] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul) + | (((nib >> 0) & 1u) ? bit : 0ul); + } + } + gSpreadBuilt = true; +} + + // ----- Present -------------------------------------------------------------- // Copy the surface's four planes into the TEXT PLANE at $E00000. @@ -123,27 +315,34 @@ void jlpShutdown(void) { // // Dirty rows only, and only the marked band within a row. void jlpPresent(const jlSurfaceT *src) { - X68kPlanarT *pd; - uint16_t y; - uint16_t plane; - uint16_t firstByte; - uint16_t rowBytes; - const uint8_t *s; - uint8_t *d; + X68kPlanarT *pd; + volatile uint32_t *wordDst; + const uint8_t *p0; + const uint8_t *p1; + const uint8_t *p2; + const uint8_t *p3; + uint32_t rowBase; + uint16_t y; + uint16_t firstByte; + uint16_t rowBytes; + uint32_t pal2; + uint16_t b; + uint16_t hi0, hi1, hi2, hi3; + uint16_t lo0, lo1, lo2, lo3; + uint8_t palBase; if (src == NULL) { return; } vdispPoll(); // keep the frame counter honest: see the note on vdispPoll - // Push the palette when it has changed. Without this NOTHING ever uploads - // colours and the display keeps whatever Human68k left in the text palette. - // The text plane has ONE 16-entry palette, so SCB per-scanline palette - // selection cannot be honoured -- palette 0 is used, matching the Atari ST. + // Reload the 256-entry graphics palette when a palette changed. The SCB + // flag is NOT consumed: the per-line palette choice is applied below in the + // pixel expansion, which reads src->scb directly (as DOS does). + if (!gSpreadBuilt) { + buildSpreadTables(); + } if (gStagePaletteDirty) { - uint16_t c; - for (c = 0u; c < 16u; c++) { - X68K_TEXT_PALETTE[c] = x68kColorFromRgb12(src->palette[0][c]); - } + uploadGfxPalette(src); gStagePaletteDirty = false; } pd = x68kSurfacePlanar(src); @@ -160,14 +359,39 @@ void jlpPresent(const jlSurfaceT *src) { if ((uint16_t)(firstByte + rowBytes) > X68K_BYTES_PER_ROW) { rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte); } - for (plane = 0u; plane < X68K_BITPLANES; plane++) { - s = pd->planes[plane] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; - // Centred: shift down X68K_ORIGIN_Y rows and right X68K_ORIGIN_BYTE - // bytes. X is byte-aligned (96/8 = 12) so no bit shifting is needed. - d = (uint8_t *)(X68K_TVRAM_PLANE0 + ((uint32_t)plane * X68K_TVRAM_PLANE_STEP) - + x68kTvramOffset(0u, (uint16_t)(y + X68K_ORIGIN_Y)) - + X68K_ORIGIN_BYTE + firstByte); - memcpy(d, s, rowBytes); + // This row's band selects the high nibble of every pixel byte -- the + // whole SCB mechanism, exactly src/dos/hal.c:83. + palBase = (uint8_t)(src->scb[y] << 4); + p0 = pd->planes[0] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; + p1 = pd->planes[1] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; + p2 = pd->planes[2] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; + p3 = pd->planes[3] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; + // Centred in the 512x512 screen. One 16-bit GVRAM word per pixel, and + // the palette index is that word's LOW byte -- odd address on this + // big-endian bus. + rowBase = X68K_GVRAM_BASE + + (((uint32_t)(y + X68K_ORIGIN_Y) * X68K_GVRAM_WORDS_ROW) + + X68K_ORIGIN_X + ((uint32_t)firstByte * 8u)) * 2u; + wordDst = (volatile uint32_t *)rowBase; + // Two long stores per four pixels; each long is two 16-bit GVRAM words. + pal2 = (uint32_t)(((uint32_t)palBase << 16) | (uint32_t)palBase); + for (b = 0u; b < rowBytes; b++) { + hi0 = (uint16_t)(p0[b] >> 4); + hi1 = (uint16_t)(p1[b] >> 4); + hi2 = (uint16_t)(p2[b] >> 4); + hi3 = (uint16_t)(p3[b] >> 4); + lo0 = (uint16_t)(p0[b] & 0x0Fu); + lo1 = (uint16_t)(p1[b] & 0x0Fu); + lo2 = (uint16_t)(p2[b] & 0x0Fu); + lo3 = (uint16_t)(p3[b] & 0x0Fu); + *wordDst++ = gSpreadHi[0][hi0] | gSpreadHi[1][hi1] + | gSpreadHi[2][hi2] | gSpreadHi[3][hi3] | pal2; + *wordDst++ = gSpreadLo[0][hi0] | gSpreadLo[1][hi1] + | gSpreadLo[2][hi2] | gSpreadLo[3][hi3] | pal2; + *wordDst++ = gSpreadHi[0][lo0] | gSpreadHi[1][lo1] + | gSpreadHi[2][lo2] | gSpreadHi[3][lo3] | pal2; + *wordDst++ = gSpreadLo[0][lo0] | gSpreadLo[1][lo1] + | gSpreadLo[2][lo2] | gSpreadLo[3][lo3] | pal2; } } } @@ -339,9 +563,24 @@ void jlpWaitVBL(void) { } +// Frames since init, derived from the honest millisecond tick rather than from +// counting V-DISP edges. +// +// The edge counter (vdispPoll) can only see a transition while the CPU happens +// to be inside a JoeyLib call, so any op that spans more than one frame loses +// the edges in between. That undercount is what made UBER's "16 frame" windows +// run for 88 seconds and what made every op report the same throughput. The +// tick is interrupt-driven and cannot miss, so time -> frames is monotonic and +// correct regardless of what the caller is doing. +// +// vdispPoll still runs: jlpWaitVBL needs the real V-DISP edge to synchronise +// to, and gFrameCount remains the fallback when the tick could not be hooked. uint16_t jlpFrameCount(void) { vdispPoll(); - return gFrameCount; + if (!gTickHooked) { + return gFrameCount; + } + return (uint16_t)((jlpMillisElapsed() * (uint32_t)jlpFrameHz()) / 1000ul); } @@ -371,6 +610,29 @@ void jlpLogTee(const char *text) { #endif +// Milliseconds since the first call. Latches its base explicitly rather than +// treating 0 as a sentinel, because 0 is a legal tick value at power-on and at +// every wrap. Falls back to the frame-derived generic clock if the tick could +// not be hooked, which is wrong in the ways described above but better than +// returning a constant. +uint32_t jlpMillisElapsed(void) { + uint32_t ticks; + + if (!gTickHooked) { + return jlpGenericMillisElapsed(); + } + ticks = gX68kTicks; + if (!gTickBaseSet) { + gTickBase = ticks; + gTickBaseSet = true; + } + // Scale by the measured rate. Multiply first (ticks fit comfortably in 32 + // bits for any realistic session) so the divide does not quantize away the + // sub-tick remainder. + return (uint32_t)(((ticks - gTickBase) * 1000ul) / gTickHz); +} + + uint16_t jlpFrameHz(void) { // 31 kHz modes on this machine run at ~55.5 Hz rather than 60. Reported // only -- the generic millisElapsed divides by it. diff --git a/src/x68000/x68kTickIsr.s b/src/x68000/x68kTickIsr.s new file mode 100644 index 0000000..57ca489 --- /dev/null +++ b/src/x68000/x68kTickIsr.s @@ -0,0 +1,30 @@ +| Sharp X68000 millisecond tick. +| +| Human68k already runs MFP Timer C as its ~100 Hz system tick, so rather than +| program a timer of our own we CHAIN its interrupt: bump a counter, then jump +| to whatever handler was there before. That keeps the OS clock working and +| costs one add per tick. +| +| Entered DIRECTLY from 68000 exception vector $45 (MFP channel 5 = Timer C, +| vector base $40 | 5), so this is a raw interrupt handler -- it must not +| disturb any register and must not return with RTS. It does neither: addq.l on +| an absolute address touches no register, and control leaves through a JMP to +| the previous handler, which owns the RTE. +| +| The JMP's operand is patched at install time by installTickIsr() in hal.c via +| the x68kTickChainAddr symbol below. Patching the operand rather than doing an +| indirect jump through a pointer is what keeps this register-free -- a 68000 +| has no jmp ([abs]) addressing mode. +| +| GAS m68k syntax, ELF target -- symbols carry NO leading underscore. + + .text + .even + .globl x68kTickIsr + .globl x68kTickChainAddr + +x68kTickIsr: + addq.l #1,gX68kTicks + .word 0x4EF9 | jmp +x68kTickChainAddr: + .long 0 | patched with the previous handler