mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 04:36:00 -10:00
* fix(cd): serialize the HLE CD streaming state (#787) The HLE BIOS streams CD_read data into RAM at drive rate from statics in jagcd_hle.c (hleStream, the CD_read/CD_poll bookkeeping, the #766 align phase) that were never in the state blob. Measured with test_runahead_determinism: Hover Strike (HLE) failed all four checks at warmup 200/400/700/1000; Myst (HLE) failed while its reads were in flight (200/400) and passed after. Cart (yarc) and Hover Strike with the real BIOS at 1000 pass, so the leak was the HLE path. Append an HLE chunk strictly last, behind a magic word ("HLE1"), extending v15 in place (v15 is still develop-only). Field by field, never the raw struct (padding, sizeof(bool)). hle_active is boot-strategy configuration and is not saved. A damaged bufOff/written drops the transfer rather than reading past the sector buffer. Backward compatibility, measured by saving at frame 400 (mid-stream) and loading into this build: - v14 states from v3.6.1 load and run byte-identically (per-frame hash CSV) to v3.6.1 loading its own state, for Hover Strike and Myst. - v15 states written by develop before this change (zero-filled tail) load with the in-flight transfer dropped, as before. - New states resume the transfer (Hover Strike: 200 distinct frames after load vs a frozen black screen without the chunk). A pre-#787 state saved mid-read still cannot resume that read on any build -- that data was never saved. After: all eight runs above pass all four checks. make test gains a ROM-gated Hover Strike HLE run-ahead check at warmup 400 (passes here, exits 1 on the pre-#787 build). Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cd): validate a restored HLE stream; synthetic mid-stream round trip Review on #803: - Copilot: a damaged or crafted state could set dest near $FFFFFFFF, wrapping HLEStreamFinish's dest+total bound check into an out-of-bounds write, or point the GPU data base where CD_poll's +0/+8 reads land outside RAM. Load now requires dest/total/reqTotal/written and the CD_poll range to fit main RAM, and both data bases to be 0, main RAM or GPU local RAM; otherwise it drops the transfer and those pointers. - Copilot: CI has no private discs, so nothing exercised an active stream through save/load. test_cd_synth_read gains inflight_stream_survives_savestate_round_trip on its synthetic disc: save mid-stream, finish, restore, and the same transfer must be in flight and complete byte-exact. Red with the chunk load disabled. - Kimi: check room for the ~2.4 KB chunk before writing it, not after; JaguarCDHLEStateSize() is pinned to what Save writes by the same test. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
948 lines
35 KiB
C
948 lines
35 KiB
C
/*
|
|
* test_cd_synth_read.c -- HLE CD_read delivery semantics, pinned against a
|
|
* SYNTHETIC disc image and a SYNTHETIC 68K boot stub.
|
|
*
|
|
* Every other CD test in this tree needs test/roms/private (gitignored,
|
|
* absent in CI and on a fresh clone), so the HLE CD read path -- the code
|
|
* that actually moves disc bytes into Jaguar RAM -- had zero committed
|
|
* regression coverage. This test builds its own disc and its own boot ROM
|
|
* at runtime, so it runs anywhere.
|
|
*
|
|
* The disc is a two-session CUE/BIN pair (same shape test_cd_pregap.c
|
|
* synthesises, which the boot-stub extractor is known to accept):
|
|
*
|
|
* track01 4 sectors, session 1, filler
|
|
* track02 session 2:
|
|
* sector 0..7 Atari universal boot header + 68K boot stub
|
|
* sector 8..39 PAYLOAD: a deterministic, position-derived byte
|
|
* pattern in 1..251 (never $00, never $FC, never
|
|
* $FF) so "delivered", "left untouched", "zeroed"
|
|
* and "overwritten by the completion pad" are four
|
|
* distinguishable outcomes.
|
|
*
|
|
* The boot stub is real 68K code:
|
|
* movea.l #dest,a0 / movea.l #end,a1 / move.l #msf,d0 / move.l #1,d1
|
|
* jsr $303C ; CD_read, through the HLE jump table
|
|
* bra.s *
|
|
*
|
|
* D1 = 1 puts CD_read on its "D1 is a counter/ID" path: no sentinel scan,
|
|
* raw stream from the requested LBA at offset 0. That makes the source
|
|
* position exactly determined by D0, which is what lets every byte be
|
|
* predicted. ($3072 bit 7 is left at the $FF the HLE boot writes, i.e.
|
|
* match-ISR mode, so the non-match raw-alignment scan is not involved.)
|
|
*
|
|
* Invariants pinned (each has a documented negative control -- see the
|
|
* per-test comments):
|
|
*
|
|
* 1. LBA addressing -- a read at LBA L returns the bytes
|
|
* written at L in the image.
|
|
* 2. Long-rounded tail -- total = (count+3)&~3 while the game
|
|
* asked for `count`; the tail of the last
|
|
* longword must hold the NEXT REAL DISC
|
|
* BYTES, not zeros and not the $FF pad.
|
|
* (Iron Soldier 2 checksums through it.)
|
|
* 3. Streamed, not instant -- CD_read itself copies nothing; bytes
|
|
* arrive over many ticks; the far end of
|
|
* the buffer is still untouched after one
|
|
* tick. (Instant delivery stomped
|
|
* in-flight poll code: Hover Strike.)
|
|
* 4. Arm over in-flight -- the previous transfer's tail is dropped
|
|
* and its completion side effects (pad +
|
|
* ATRI block) never fire.
|
|
* 5. End-to-end through 68K -- the same read driven by the synthetic
|
|
* boot stub via retro_run, not by poking
|
|
* the hook directly.
|
|
*
|
|
* Build: make test/test_cd_synth_read (needs TEST_EXPORTS=1)
|
|
* Run: DYLD_LIBRARY_PATH=. test/test_cd_synth_read
|
|
*/
|
|
|
|
#include "cd_assertions.h"
|
|
#include "../libretro-common/include/libretro.h"
|
|
|
|
#include <unistd.h>
|
|
#include <sys/stat.h>
|
|
#include <errno.h>
|
|
|
|
static struct vj_core C;
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Core internals resolved by dlsym */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static bool (*p_hle_hook)(uint32_t);
|
|
static void (*p_hle_tick)(void);
|
|
static bool (*p_hle_active)(void);
|
|
static uint32_t (*p_hle_dest)(void);
|
|
static uint32_t (*p_hle_bytes)(void);
|
|
static uint32_t (*p_hle_arms)(void);
|
|
static bool (*p_cd_read_block)(uint32_t, uint8_t *);
|
|
static uint32_t (*p_cd_s2_first)(void);
|
|
static uint32_t (*p_cd_total)(void);
|
|
static bool (*p_load_game)(const struct retro_game_info *);
|
|
static void (*p_unload_game)(void);
|
|
static void (*p_run)(void);
|
|
static size_t (*p_ser_size)(void);
|
|
static bool (*p_serialize)(void *, size_t);
|
|
static bool (*p_unserialize)(const void *, size_t);
|
|
static size_t (*p_hle_state_save)(uint8_t *);
|
|
static size_t (*p_hle_state_size)(void);
|
|
|
|
static uint8_t *ram;
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Synthetic disc geometry */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SECTOR_BYTES 2352u
|
|
#define S1_SECTORS 4u
|
|
#define PAYLOAD_SECTOR 8u /* payload starts this many sectors into track 02 */
|
|
#define PAYLOAD_SECTORS 32u
|
|
#define T2_SECTORS (PAYLOAD_SECTOR + PAYLOAD_SECTORS)
|
|
#define PAYLOAD_BASE_OFF (PAYLOAD_SECTOR * SECTOR_BYTES)
|
|
|
|
/* Atari universal boot header (offsets as used by CDIntfExtractBootStub). */
|
|
#define HDR_MAGIC_OFF 0x42u
|
|
#define HDR_LOAD_OFF 0x62u
|
|
#define HDR_LEN_OFF 0x66u
|
|
#define HDR_PAYLOAD 0x6Au
|
|
#define STUB_LOAD_ADDR 0x004000u
|
|
#define STUB_LENGTH 0x100u
|
|
|
|
static const char BOOT_MAGIC[32] = "ATARI APPROVED DATA HEADER ATRI ";
|
|
|
|
/* Destination buffers in main RAM. Clear of the 68K vector table, the
|
|
* $400 RTE stub, the TOC at $2C00, the jump table at $3000, the injected
|
|
* boot stub at $4000, the CD-ready flag at $3727C and SP at $200000. */
|
|
#define DEST_A 0x100000u
|
|
#define DEST_B 0x140000u
|
|
|
|
/* Filler written into the destination before every read. Outside the
|
|
* payload pattern's 1..251 range, and distinct from both $00 (a zeroing
|
|
* bug) and $FF (the completion pad). */
|
|
#define FILLER 0xFCu
|
|
|
|
/* The stub's own read, for the end-to-end test: deliberately not a
|
|
* multiple of 4 so it doubles as a tail check. */
|
|
#define STUB_READ_BYTES 0x1005u
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Deterministic payload pattern */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Raw byte stored at track-02 file offset `off`. Always in 1..251. */
|
|
static uint8_t disc_pat(uint32_t off)
|
|
{
|
|
return (uint8_t)(1u + ((off * 37u + (off >> 8) * 11u) % 251u));
|
|
}
|
|
|
|
/* Byte the streamer must land at dest + i for a read that starts at the
|
|
* payload LBA, offset 0.
|
|
*
|
|
* The streamer un-does the I2S word swap: within each 2352-byte sector it
|
|
* exchanges byte pairs (0,1), (2,3), ... PAYLOAD_BASE_OFF and SECTOR_BYTES
|
|
* are both even, so sector-local parity is global parity and the swap is
|
|
* just `^1` on the file offset. */
|
|
static uint8_t expect_ram(uint32_t i)
|
|
{
|
|
return disc_pat(PAYLOAD_BASE_OFF + (i ^ 1u));
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Byte helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void put16(uint8_t *p, uint16_t v)
|
|
{
|
|
p[0] = (uint8_t)(v >> 8);
|
|
p[1] = (uint8_t)v;
|
|
}
|
|
|
|
static void put32(uint8_t *p, uint32_t v)
|
|
{
|
|
p[0] = (uint8_t)(v >> 24);
|
|
p[1] = (uint8_t)(v >> 16);
|
|
p[2] = (uint8_t)(v >> 8);
|
|
p[3] = (uint8_t)v;
|
|
}
|
|
|
|
/* LBA -> the packed MSF CD_read expects in D0: (min<<16)|(sec<<8)|frm,
|
|
* with the 150-sector lead-in added back (CD_read subtracts it). */
|
|
static uint32_t lba_to_msf(uint32_t lba)
|
|
{
|
|
uint32_t tf = lba + 150u;
|
|
uint32_t min = tf / 4500u;
|
|
uint32_t sec = (tf / 75u) % 60u;
|
|
uint32_t frm = tf % 75u;
|
|
return (min << 16) | (sec << 8) | frm;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Synthetic ROM: the 68K boot stub */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Emits, at `out` (STUB_LENGTH bytes, logical/unswapped order):
|
|
* 207C dddddddd movea.l #dest,a0
|
|
* 227C eeeeeeee movea.l #dest+size,a1
|
|
* 203C mmmmmmmm move.l #msf,d0
|
|
* 223C 00000001 move.l #1,d1
|
|
* 4EB9 0000303C jsr $0000303C ; CD_read
|
|
* 60FE bra.s * ; park
|
|
* with the remainder filled with `bra.s *` so any stray entry parks too. */
|
|
static void build_stub(uint8_t *out, uint32_t msf, uint32_t dest, uint32_t size)
|
|
{
|
|
uint32_t o = 0;
|
|
|
|
put16(out + o, 0x207C); put32(out + o + 2, dest); o += 6;
|
|
put16(out + o, 0x227C); put32(out + o + 2, dest + size); o += 6;
|
|
put16(out + o, 0x203C); put32(out + o + 2, msf); o += 6;
|
|
put16(out + o, 0x223C); put32(out + o + 2, 0x00000001u); o += 6;
|
|
put16(out + o, 0x4EB9); put32(out + o + 2, 0x0000303Cu); o += 6;
|
|
|
|
while (o + 1u < STUB_LENGTH)
|
|
{
|
|
put16(out + o, 0x60FE);
|
|
o += 2;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Synthetic disc construction */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static bool write_file(const char *path, const uint8_t *data, size_t len)
|
|
{
|
|
FILE *f = fopen(path, "wb");
|
|
size_t n;
|
|
|
|
if (!f)
|
|
return false;
|
|
n = len ? fwrite(data, 1, len, f) : 0;
|
|
fclose(f);
|
|
return n == len;
|
|
}
|
|
|
|
/* Writes <dir>/disc.cue + track01.bin + track02.bin.
|
|
*
|
|
* `stubMsf` is baked into the boot stub's `move.l #msf,d0`. The payload
|
|
* LBA is only known after the image loader has told us where session 2
|
|
* starts, so the disc is built twice: once with a placeholder, then again
|
|
* with the real value. Track sizes never change, so the geometry the
|
|
* loader reports is identical for both builds. */
|
|
static bool make_disc(const char *dir, uint32_t stubMsf,
|
|
char *cueOut, size_t cueOutLen)
|
|
{
|
|
char path[1024];
|
|
uint8_t *t1 = NULL;
|
|
uint8_t *t2 = NULL;
|
|
uint8_t *logical = NULL;
|
|
size_t t1Len = (size_t)S1_SECTORS * SECTOR_BYTES;
|
|
size_t t2Len = (size_t)T2_SECTORS * SECTOR_BYTES;
|
|
size_t i;
|
|
FILE *cue;
|
|
bool ok = false;
|
|
|
|
t1 = (uint8_t *)calloc(1, t1Len);
|
|
t2 = (uint8_t *)calloc(1, t2Len);
|
|
logical = (uint8_t *)calloc(1, PAYLOAD_BASE_OFF);
|
|
if (!t1 || !t2 || !logical)
|
|
goto done;
|
|
|
|
/* Header + stub, built in logical order then I2S-swapped onto the disc. */
|
|
memcpy(logical + HDR_MAGIC_OFF, BOOT_MAGIC, sizeof(BOOT_MAGIC));
|
|
put32(logical + HDR_LOAD_OFF, STUB_LOAD_ADDR);
|
|
put32(logical + HDR_LEN_OFF, STUB_LENGTH);
|
|
build_stub(logical + HDR_PAYLOAD, stubMsf, DEST_A, STUB_READ_BYTES);
|
|
|
|
for (i = 0; i + 1 < PAYLOAD_BASE_OFF; i += 2)
|
|
{
|
|
t2[i] = logical[i + 1];
|
|
t2[i + 1] = logical[i];
|
|
}
|
|
|
|
/* Payload region: raw disc bytes, straight from the pattern. */
|
|
for (i = PAYLOAD_BASE_OFF; i < t2Len; i++)
|
|
t2[i] = disc_pat((uint32_t)i);
|
|
|
|
snprintf(path, sizeof(path), "%s/track01.bin", dir);
|
|
if (!write_file(path, t1, t1Len))
|
|
goto done;
|
|
snprintf(path, sizeof(path), "%s/track02.bin", dir);
|
|
if (!write_file(path, t2, t2Len))
|
|
goto done;
|
|
|
|
snprintf(cueOut, cueOutLen, "%s/disc.cue", dir);
|
|
cue = fopen(cueOut, "wb");
|
|
if (!cue)
|
|
goto done;
|
|
fprintf(cue,
|
|
"REM SESSION 01\n"
|
|
"FILE \"track01.bin\" BINARY\n"
|
|
" TRACK 01 AUDIO\n"
|
|
" INDEX 01 00:00:00\n"
|
|
"REM SESSION 02\n"
|
|
"FILE \"track02.bin\" BINARY\n"
|
|
" TRACK 02 AUDIO\n"
|
|
" INDEX 01 00:00:00\n");
|
|
fclose(cue);
|
|
ok = true;
|
|
|
|
done:
|
|
free(t1);
|
|
free(t2);
|
|
free(logical);
|
|
return ok;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* libretro plumbing */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static bool env_cb(unsigned cmd, void *data)
|
|
{
|
|
if (cmd == RETRO_ENVIRONMENT_GET_VARIABLE)
|
|
{
|
|
struct retro_variable *var = (struct retro_variable *)data;
|
|
if (strcmp(var->key, "virtualjaguar_cd_boot_mode") == 0)
|
|
{
|
|
var->value = "hle";
|
|
return true;
|
|
}
|
|
/* Everything else defaults -- notably virtualjaguar_cd_read_speed,
|
|
* whose default is CDSPEED_2X (paced). CDSPEED_INSTANT would make
|
|
* the streaming test vacuous. */
|
|
return false;
|
|
}
|
|
if (cmd == RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY)
|
|
{
|
|
*(const char **)data = ".";
|
|
return true;
|
|
}
|
|
if (cmd == RETRO_ENVIRONMENT_SET_PIXEL_FORMAT)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
static void vcb(const void *d, unsigned w, unsigned h, size_t p)
|
|
{ (void)d; (void)w; (void)h; (void)p; }
|
|
static void acb(int16_t l, int16_t r) { (void)l; (void)r; }
|
|
static size_t abcb(const int16_t *d, size_t f) { (void)d; return f; }
|
|
static void ipcb(void) {}
|
|
static int16_t iscb(unsigned p, unsigned d, unsigned i, unsigned id)
|
|
{ (void)p; (void)d; (void)i; (void)id; return 0; }
|
|
|
|
/* Remove the scratch disc. Called on every exit path -- an aborted run
|
|
* must not leave ~94 KB of synthetic BIN behind. */
|
|
static void scrub_scratch(const char *base)
|
|
{
|
|
char path[1024];
|
|
|
|
snprintf(path, sizeof(path), "%s/track01.bin", base); remove(path);
|
|
snprintf(path, sizeof(path), "%s/track02.bin", base); remove(path);
|
|
snprintf(path, sizeof(path), "%s/disc.cue", base); remove(path);
|
|
rmdir(base);
|
|
}
|
|
|
|
static bool load_disc(const char *cue)
|
|
{
|
|
struct retro_game_info info;
|
|
memset(&info, 0, sizeof(info));
|
|
info.path = cue;
|
|
return p_load_game(&info);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static uint32_t payloadLBA; /* resolved after the first load */
|
|
static uint32_t s2FirstLBA;
|
|
static uint32_t discSectors;
|
|
|
|
/* Arm a CD_read exactly as the 68K entry does: registers, then the HLE
|
|
* jump-table hook at $303C. */
|
|
static void arm_read(uint32_t lba, uint32_t dest, uint32_t bytes)
|
|
{
|
|
C.m68k_set_reg(M68K_REG_D0, lba_to_msf(lba));
|
|
C.m68k_set_reg(M68K_REG_D1, 0x00000001u);
|
|
C.m68k_set_reg(M68K_REG_A0, dest);
|
|
C.m68k_set_reg(M68K_REG_A1, dest + bytes);
|
|
p_hle_hook(0x0000303Cu);
|
|
}
|
|
|
|
static void fill_dest(uint32_t dest, uint32_t len)
|
|
{
|
|
memset(ram + dest, FILLER, len);
|
|
}
|
|
|
|
static uint32_t drive_stream(uint32_t maxTicks)
|
|
{
|
|
uint32_t n = 0;
|
|
while (p_hle_active() && n < maxTicks)
|
|
{
|
|
p_hle_tick();
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/* Index of the first byte in [0,len) that differs from expect_ram(), or
|
|
* len when the whole range matches. */
|
|
static uint32_t first_bad(uint32_t dest, uint32_t len)
|
|
{
|
|
uint32_t i;
|
|
for (i = 0; i < len; i++)
|
|
if (ram[dest + i] != expect_ram(i))
|
|
return i;
|
|
return len;
|
|
}
|
|
|
|
/* HLEStreamFinish's end-of-transfer marker: 8 bytes of $FF immediately
|
|
* overwritten by 16 "ATRI" longwords, both starting at dest + total. Its
|
|
* position is the observable that says where the transfer was considered to
|
|
* end -- at the long-rounded end, never at the requested end. */
|
|
static bool is_atri(uint32_t addr)
|
|
{
|
|
return ram[addr + 0] == 0x41u && ram[addr + 1] == 0x54u &&
|
|
ram[addr + 2] == 0x52u && ram[addr + 3] == 0x49u;
|
|
}
|
|
|
|
static bool range_is(uint32_t addr, uint32_t len, uint8_t v)
|
|
{
|
|
uint32_t i;
|
|
for (i = 0; i < len; i++)
|
|
if (ram[addr + i] != v)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 1. LBA addressing / image model */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Pins that a given absolute LBA hands back exactly the bytes written at
|
|
* that position in the synthetic image, and establishes the preconditions
|
|
* every later test relies on (payload inside the session-2 range, so
|
|
* CD_read's out-of-range redirect never fires, and inside the disc, so
|
|
* CDIntfReadBlock never fails into a zero-filled buffer).
|
|
*
|
|
* Negative control: `hleStream.lba = scanLBA + 1` in HLEHandleCDRead makes
|
|
* test 2 fail at offset 0 -- this test localises whether such a failure is
|
|
* the streamer or the image loader. */
|
|
TEST(synth_disc_lba_addressing)
|
|
{
|
|
static uint8_t buf[SECTOR_BYTES];
|
|
uint32_t k;
|
|
uint32_t s;
|
|
|
|
ASSERT_TRUE(s2FirstLBA > 0);
|
|
ASSERT_TRUE(discSectors > 0);
|
|
/* Payload must sit strictly inside [s2first, discTotal): outside it,
|
|
* CD_read redirects to the session-2 game-data LBA and reads something
|
|
* else entirely. */
|
|
ASSERT_TRUE(payloadLBA >= s2FirstLBA);
|
|
ASSERT_TRUE(payloadLBA + PAYLOAD_SECTORS <= discSectors);
|
|
|
|
for (s = 0; s < 4; s++)
|
|
{
|
|
ASSERT_TRUE(p_cd_read_block(payloadLBA + s, buf));
|
|
for (k = 0; k < SECTOR_BYTES; k++)
|
|
{
|
|
if (buf[k] != disc_pat(PAYLOAD_BASE_OFF + s * SECTOR_BYTES + k))
|
|
FAIL("LBA %u byte %u: got $%02X, expected $%02X",
|
|
payloadLBA + s, k, buf[k],
|
|
disc_pat(PAYLOAD_BASE_OFF + s * SECTOR_BYTES + k));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 2. Long-rounded tail delivery */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* hleStream.total = (count+3)&~3 while reqTotal = count. The bytes in
|
|
* [count, total) are NOT padding: the real GPU CD ISR stores whole
|
|
* longwords, so those positions hold the next real bytes off the disc.
|
|
* Iron Soldier 2's boot stub checksums its $14FE-byte load in ADD.L steps
|
|
* and sums 2 bytes past its own end address; delivering exact-size (and
|
|
* therefore putting the $FF pad at the odd end) made every validation fail
|
|
* and the stub re-issue the read forever.
|
|
*
|
|
* Negative control (verified): change
|
|
* hleStream.total = (byteCount + 3u) & ~3u;
|
|
* to
|
|
* hleStream.total = byteCount;
|
|
* -- the tail then reads $FF (the completion pad now starts at reqTotal)
|
|
* and this test goes red on every unaligned size. */
|
|
TEST(long_rounded_tail_holds_real_disc_bytes)
|
|
{
|
|
static const uint32_t sizes[7] = { 1u, 2u, 3u, 5u, 7u, 4093u, 4096u };
|
|
uint32_t n;
|
|
|
|
for (n = 0; n < 7; n++)
|
|
{
|
|
uint32_t size = sizes[n];
|
|
uint32_t total = (size + 3u) & ~3u;
|
|
uint32_t bad;
|
|
uint32_t i;
|
|
|
|
/* Filler across the request, its long-rounded tail, and the pad +
|
|
* ATRI region beyond, so nothing is inherited from the last pass. */
|
|
fill_dest(DEST_A, total + 128u);
|
|
|
|
arm_read(payloadLBA, DEST_A, size);
|
|
ASSERT_TRUE(p_hle_active());
|
|
drive_stream(200000u);
|
|
if (p_hle_active())
|
|
FAIL("size %u: stream never completed", size);
|
|
|
|
bad = first_bad(DEST_A, total);
|
|
if (bad != total)
|
|
FAIL("size %u: byte %u of %u: got $%02X, expected $%02X",
|
|
size, bad, total, ram[DEST_A + bad], expect_ram(bad));
|
|
|
|
/* Restate the tail explicitly -- this is the load-bearing part and
|
|
* it must not be silently covered by the loop above. */
|
|
for (i = size; i < total; i++)
|
|
{
|
|
if (ram[DEST_A + i] == 0xFF)
|
|
FAIL("size %u: tail byte %u is the $FF completion pad, "
|
|
"not disc data", size, i);
|
|
if (ram[DEST_A + i] == 0x00)
|
|
FAIL("size %u: tail byte %u is zero, not disc data", size, i);
|
|
if (ram[DEST_A + i] == FILLER)
|
|
FAIL("size %u: tail byte %u was never written", size, i);
|
|
ASSERT_EQ_U8(ram[DEST_A + i], expect_ram(i));
|
|
}
|
|
|
|
/* The wire count itself is long-rounded. */
|
|
ASSERT_EQ_U32(p_hle_bytes(), total);
|
|
|
|
/* And the end-of-transfer marker starts exactly AT the long-rounded
|
|
* end, so it can never eat a delivered byte. (Delivering exact-size
|
|
* put it at the odd end address and clobbered the checksummed tail.) */
|
|
if (!is_atri(DEST_A + total))
|
|
FAIL("size %u: no end marker at the long-rounded end $%06X "
|
|
"(got $%02X%02X%02X%02X)", size, DEST_A + total,
|
|
ram[DEST_A + total], ram[DEST_A + total + 1],
|
|
ram[DEST_A + total + 2], ram[DEST_A + total + 3]);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 3. Streamed, not instant */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Games issue overlay loads whose destination covers the very code that
|
|
* polls for completion (Hover Strike's LVL loads span $05D340-$1F0000 with
|
|
* its poll loop at $1B4xxx). On hardware the drive streams at 352,800 B/s,
|
|
* giving the game time to jump away. An instant CD_read stomps the running
|
|
* poll loop and the 68K double-faults.
|
|
*
|
|
* Deliberately no predicted per-tick byte count (that depends on the
|
|
* halfline period and the NTSC/PAL flag): the assertions are two-sided and
|
|
* model-free -- the front of the buffer has moved, the far end has not.
|
|
*
|
|
* Negative control (verified): force `hleStream.speedMult = CDSPEED_INSTANT`
|
|
* at arm time -- the whole transfer lands in the first tick, the far byte is
|
|
* no longer filler, and this test goes red. */
|
|
TEST(delivery_is_streamed_not_instant)
|
|
{
|
|
uint32_t size = 0x8000u;
|
|
uint32_t ticks;
|
|
uint32_t bad;
|
|
|
|
fill_dest(DEST_A, size + 128u);
|
|
arm_read(payloadLBA, DEST_A, size);
|
|
|
|
/* CD_read itself must copy nothing -- it only arms. */
|
|
ASSERT_TRUE(p_hle_active());
|
|
ASSERT_EQ_U32(p_hle_dest(), DEST_A);
|
|
if (!range_is(DEST_A, size, FILLER))
|
|
FAIL("CD_read wrote to the destination before any tick");
|
|
|
|
/* One tick: the front has advanced, the far end has not. */
|
|
p_hle_tick();
|
|
ASSERT_EQ_U8(ram[DEST_A], expect_ram(0));
|
|
ASSERT_EQ_U8(ram[DEST_A + size - 1u], FILLER);
|
|
ASSERT_TRUE(p_hle_active());
|
|
|
|
/* And it takes many more ticks to finish -- a single-tick transfer is
|
|
* exactly the instant delivery this pins against. */
|
|
ticks = 1u + drive_stream(200000u);
|
|
if (p_hle_active())
|
|
FAIL("stream never completed (%u ticks)", ticks);
|
|
if (ticks < 100u)
|
|
FAIL("%u-byte transfer completed in %u ticks -- not drive-paced",
|
|
size, ticks);
|
|
|
|
bad = first_bad(DEST_A, size);
|
|
if (bad != size)
|
|
FAIL("byte %u: got $%02X, expected $%02X",
|
|
bad, ram[DEST_A + bad], expect_ram(bad));
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 3b. An in-flight stream survives a savestate round trip (#787) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* The HLE stream lives in jagcd_hle.c statics. Before #787 none of it was
|
|
* in the state blob, so restoring a state saved mid-transfer left the
|
|
* stream wherever the live session had taken it -- here, already finished
|
|
* -- and the bytes still owed after the save point never arrived: exactly
|
|
* what run-ahead's rollback does every frame. Save part-way through,
|
|
* finish the transfer, restore, and the SAME transfer must be in flight
|
|
* again and complete byte-exact.
|
|
*
|
|
* Negative control (verified): on the pre-#787 core the stream is inactive
|
|
* right after the restore and this test goes red. */
|
|
TEST(inflight_stream_survives_savestate_round_trip)
|
|
{
|
|
uint32_t size = 0x8000u;
|
|
uint32_t totalAtSave, ticks, bad, i;
|
|
size_t ssz;
|
|
uint8_t *state;
|
|
|
|
/* The room check in retro_serialize trusts JaguarCDHLEStateSize(). */
|
|
{
|
|
static uint8_t chunk[8192];
|
|
ASSERT_EQ_U32((uint32_t)p_hle_state_save(chunk),
|
|
(uint32_t)p_hle_state_size());
|
|
}
|
|
|
|
ssz = p_ser_size();
|
|
state = (uint8_t *)malloc(ssz);
|
|
ASSERT_TRUE(state != NULL);
|
|
|
|
fill_dest(DEST_A, size + 128u);
|
|
arm_read(payloadLBA, DEST_A, size);
|
|
for (i = 0; i < 20u; i++)
|
|
p_hle_tick();
|
|
ASSERT_TRUE(p_hle_active());
|
|
if (ram[DEST_A + size - 1u] != FILLER)
|
|
FAIL("stream finished before the save point; shorten the warmup");
|
|
totalAtSave = p_hle_bytes();
|
|
|
|
if (!p_serialize(state, ssz))
|
|
{
|
|
free(state);
|
|
FAIL("retro_serialize failed mid-stream");
|
|
}
|
|
|
|
/* Let the live session finish the transfer. */
|
|
drive_stream(200000u);
|
|
if (p_hle_active())
|
|
{
|
|
free(state);
|
|
FAIL("stream never completed before the restore");
|
|
}
|
|
|
|
/* Roll back to the save point. */
|
|
if (!p_unserialize(state, ssz))
|
|
{
|
|
free(state);
|
|
FAIL("retro_unserialize failed");
|
|
}
|
|
free(state);
|
|
|
|
if (!p_hle_active())
|
|
FAIL("restored state has no stream in flight -- the transfer was lost");
|
|
ASSERT_EQ_U32(p_hle_dest(), DEST_A);
|
|
ASSERT_EQ_U32(p_hle_bytes(), totalAtSave);
|
|
if (ram[DEST_A + size - 1u] != FILLER)
|
|
FAIL("restored RAM already holds the tail -- RAM was not rolled back");
|
|
|
|
ticks = drive_stream(200000u);
|
|
if (p_hle_active())
|
|
FAIL("restored stream never completed (%u ticks)", ticks);
|
|
bad = first_bad(DEST_A, size);
|
|
if (bad != size)
|
|
FAIL("after restore, byte %u: got $%02X, expected $%02X",
|
|
bad, ram[DEST_A + bad], expect_ram(bad));
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 4. Arming over an in-flight stream */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* A CD_read armed while another is still streaming abandons the first one:
|
|
* its undelivered tail never arrives and -- the part that matters for
|
|
* anything downstream -- HLEStreamFinish never runs for it, so neither the
|
|
* $FF pad nor the ATRI sync block that overwrites it appears at its end. A regression that quietly
|
|
* "finished" the abandoned transfer would publish a completion the game
|
|
* never earned.
|
|
*
|
|
* The second read differs in A0/A1, so it does not take CD_read's
|
|
* identical-re-issue path (which deliberately keeps the in-flight transfer).
|
|
*
|
|
* Negative control (verified): call HLEStreamFinish() just before re-arming
|
|
* in HLEHandleCDRead -- the pad + ATRI block appear at dest_A + total_A and
|
|
* this test goes red. */
|
|
TEST(arming_over_inflight_drops_tail_and_completion)
|
|
{
|
|
uint32_t sizeA = 0x8000u;
|
|
uint32_t sizeB = 0x400u;
|
|
uint32_t totalA = (sizeA + 3u) & ~3u;
|
|
uint32_t totalB = (sizeB + 3u) & ~3u;
|
|
uint32_t arms0 = p_hle_arms();
|
|
uint32_t i;
|
|
uint32_t bad;
|
|
uint32_t delivered = 0;
|
|
|
|
fill_dest(DEST_A, sizeA + 128u);
|
|
fill_dest(DEST_B, sizeB + 128u);
|
|
|
|
arm_read(payloadLBA, DEST_A, sizeA);
|
|
for (i = 0; i < 5u; i++)
|
|
p_hle_tick();
|
|
ASSERT_TRUE(p_hle_active());
|
|
|
|
/* Partially delivered: some bytes in, most still filler. */
|
|
for (i = 0; i < totalA; i++)
|
|
{
|
|
if (ram[DEST_A + i] == FILLER)
|
|
break;
|
|
delivered++;
|
|
}
|
|
if (delivered == 0)
|
|
FAIL("stream A delivered nothing in 5 ticks");
|
|
if (delivered >= totalA)
|
|
FAIL("stream A already complete after 5 ticks -- cannot test abandon");
|
|
|
|
/* Arm B over it. */
|
|
arm_read(payloadLBA, DEST_B, sizeB);
|
|
ASSERT_EQ_U32(p_hle_arms() - arms0, 2u);
|
|
ASSERT_EQ_U32(p_hle_dest(), DEST_B);
|
|
ASSERT_EQ_U32(p_hle_bytes(), totalB);
|
|
|
|
/* A's tail was dropped: the last byte it would have delivered is still
|
|
* filler, and no completion side effects ran at its end. */
|
|
ASSERT_EQ_U8(ram[DEST_A + totalA - 1u], FILLER);
|
|
if (!range_is(DEST_A + totalA, 8u, FILLER))
|
|
FAIL("abandoned transfer A published completion side effects "
|
|
"($FF pad / ATRI block) at $%06X", DEST_A + totalA);
|
|
|
|
/* B completes normally and is byte-exact. */
|
|
drive_stream(200000u);
|
|
if (p_hle_active())
|
|
FAIL("stream B never completed");
|
|
bad = first_bad(DEST_B, totalB);
|
|
if (bad != totalB)
|
|
FAIL("B byte %u: got $%02X, expected $%02X",
|
|
bad, ram[DEST_B + bad], expect_ram(bad));
|
|
if (!is_atri(DEST_B + totalB))
|
|
FAIL("stream B did not publish its end marker at $%06X",
|
|
DEST_B + totalB);
|
|
|
|
/* A's abandoned region stayed abandoned while B ran. */
|
|
ASSERT_EQ_U8(ram[DEST_A + totalA - 1u], FILLER);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 5. End-to-end through the synthetic ROM */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Everything above drives JaguarCDHLEHook() directly. This one proves the
|
|
* whole chain is wired: the boot-stub extractor pulls our 68K program off
|
|
* the synthetic disc, M68KInstructionHook dispatches its `jsr $303C` into
|
|
* the HLE CD_read, and HalflineCallback's per-halfline StreamTick delivers
|
|
* the bytes across retro_run() frames.
|
|
*
|
|
* The stub's request is $1005 bytes -- not a multiple of 4 -- so the tail
|
|
* invariant is re-checked end-to-end.
|
|
*
|
|
* Negative control (verified): the same `hleStream.total = byteCount`
|
|
* sabotage as test 2 turns this red too. */
|
|
TEST(stub_driven_read_end_to_end)
|
|
{
|
|
uint32_t total = (STUB_READ_BYTES + 3u) & ~3u;
|
|
uint32_t arms0;
|
|
uint32_t f;
|
|
uint32_t bad;
|
|
uint32_t i;
|
|
|
|
/* Boot stub injected and entered at its load address. */
|
|
ASSERT_EQ_U32(C.m68k_get_reg(NULL, M68K_REG_PC), STUB_LOAD_ADDR);
|
|
/* Our program, not somebody's zero fill: movea.l #DEST_A,a0. */
|
|
ASSERT_EQ_U8(ram[STUB_LOAD_ADDR + 0], 0x20);
|
|
ASSERT_EQ_U8(ram[STUB_LOAD_ADDR + 1], 0x7C);
|
|
|
|
arms0 = p_hle_arms();
|
|
fill_dest(DEST_A, total + 128u);
|
|
|
|
for (f = 0; f < 30u; f++)
|
|
p_run();
|
|
|
|
if (p_hle_arms() - arms0 != 1u)
|
|
FAIL("stub issued %u CD_reads in 30 frames, expected 1",
|
|
p_hle_arms() - arms0);
|
|
if (p_hle_active())
|
|
FAIL("stub's read never completed in 30 frames");
|
|
|
|
bad = first_bad(DEST_A, total);
|
|
if (bad != total)
|
|
FAIL("byte %u of %u: got $%02X, expected $%02X",
|
|
bad, total, ram[DEST_A + bad], expect_ram(bad));
|
|
|
|
for (i = STUB_READ_BYTES; i < total; i++)
|
|
{
|
|
if (ram[DEST_A + i] == 0xFF || ram[DEST_A + i] == FILLER)
|
|
FAIL("tail byte %u not delivered from disc (got $%02X)",
|
|
i, ram[DEST_A + i]);
|
|
ASSERT_EQ_U8(ram[DEST_A + i], expect_ram(i));
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static bool resolve_symbols(void)
|
|
{
|
|
p_hle_hook = (bool (*)(uint32_t))dlsym(C.handle, "JaguarCDHLEHook");
|
|
p_hle_tick = (void (*)(void))dlsym(C.handle, "JaguarCDHLEStreamTick");
|
|
p_hle_active = (bool (*)(void))dlsym(C.handle, "JaguarCDHLEStreamActive");
|
|
p_hle_dest = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamDest");
|
|
p_hle_bytes = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamBytes");
|
|
p_hle_arms = (uint32_t (*)(void))dlsym(C.handle, "JaguarCDHLEStreamArmCount");
|
|
p_cd_read_block = (bool (*)(uint32_t, uint8_t *))dlsym(C.handle, "CDIntfReadBlock");
|
|
p_cd_s2_first = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetSession2FirstTrackLBA");
|
|
p_cd_total = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetDiscTotalSectors");
|
|
p_load_game = (bool (*)(const struct retro_game_info *))dlsym(C.handle, "retro_load_game");
|
|
p_unload_game = (void (*)(void))dlsym(C.handle, "retro_unload_game");
|
|
p_run = (void (*)(void))dlsym(C.handle, "retro_run");
|
|
p_ser_size = (size_t (*)(void))dlsym(C.handle, "retro_serialize_size");
|
|
p_serialize = (bool (*)(void *, size_t))dlsym(C.handle, "retro_serialize");
|
|
p_unserialize = (bool (*)(const void *, size_t))dlsym(C.handle, "retro_unserialize");
|
|
p_hle_state_save = (size_t (*)(uint8_t *))dlsym(C.handle, "JaguarCDHLEStateSave");
|
|
p_hle_state_size = (size_t (*)(void))dlsym(C.handle, "JaguarCDHLEStateSize");
|
|
|
|
return p_hle_hook && p_hle_tick && p_hle_active && p_hle_dest &&
|
|
p_hle_bytes && p_hle_arms && p_cd_read_block && p_cd_s2_first &&
|
|
p_cd_total && p_load_game && p_unload_game && p_run &&
|
|
p_ser_size && p_serialize && p_unserialize &&
|
|
p_hle_state_save && p_hle_state_size &&
|
|
C.m68k_set_reg && C.m68k_get_reg && C.GetRamPtr;
|
|
}
|
|
|
|
int main(int argc, char *argv[])
|
|
{
|
|
char base[512];
|
|
char cue[1024];
|
|
int rc;
|
|
|
|
(void)argc; (void)argv;
|
|
|
|
TEST_INIT("CD HLE read semantics (synthetic disc + ROM)");
|
|
|
|
if (!vj_core_load(&C))
|
|
{
|
|
fprintf(stderr, "FATAL: failed to load core\n");
|
|
return 1;
|
|
}
|
|
|
|
C.retro_set_environment(env_cb);
|
|
C.retro_set_video_refresh(vcb);
|
|
C.retro_set_audio_sample(acb);
|
|
C.retro_set_audio_sample_batch(abcb);
|
|
C.retro_set_input_poll(ipcb);
|
|
C.retro_set_input_state(iscb);
|
|
C.retro_init();
|
|
|
|
if (!resolve_symbols())
|
|
{
|
|
fprintf(stderr, " FAIL missing test exports "
|
|
"(build with `make TEST_EXPORTS=1`)\n");
|
|
dlclose(C.handle);
|
|
return 1;
|
|
}
|
|
ram = C.GetRamPtr();
|
|
if (!ram)
|
|
{
|
|
fprintf(stderr, " FAIL GetRamPtr() returned NULL\n");
|
|
dlclose(C.handle);
|
|
return 1;
|
|
}
|
|
|
|
/* Deterministic per-process scratch dir (mkdtemp sits behind different
|
|
* feature-test macros on Darwin vs glibc, and per-process is unique
|
|
* enough). Everything below is synthetic -- no test/roms/private. */
|
|
snprintf(base, sizeof(base), "/tmp/vj_cd_synth_%ld", (long)getpid());
|
|
if (mkdir(base, 0755) != 0 && errno != EEXIST)
|
|
{
|
|
fprintf(stderr, " SKIP cannot create scratch dir %s\n", base);
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
return 0;
|
|
}
|
|
|
|
/* Pass 1: placeholder MSF, just to learn the image geometry. */
|
|
if (!make_disc(base, 0, cue, sizeof(cue)))
|
|
{
|
|
fprintf(stderr, " SKIP cannot write synthetic disc under %s\n", base);
|
|
scrub_scratch(base);
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
return 0;
|
|
}
|
|
if (!load_disc(cue))
|
|
{
|
|
fprintf(stderr, " FAIL synthetic disc did not load\n");
|
|
scrub_scratch(base);
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
return 1;
|
|
}
|
|
s2FirstLBA = p_cd_s2_first();
|
|
discSectors = p_cd_total();
|
|
payloadLBA = s2FirstLBA + PAYLOAD_SECTOR;
|
|
p_unload_game();
|
|
|
|
/* Pass 2: same geometry, real MSF baked into the stub. */
|
|
if (!make_disc(base, lba_to_msf(payloadLBA), cue, sizeof(cue)))
|
|
{
|
|
fprintf(stderr, " FAIL cannot rewrite synthetic disc\n");
|
|
scrub_scratch(base);
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
return 1;
|
|
}
|
|
if (!load_disc(cue))
|
|
{
|
|
fprintf(stderr, " FAIL synthetic disc did not reload\n");
|
|
scrub_scratch(base);
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
return 1;
|
|
}
|
|
ram = C.GetRamPtr(); /* re-latch: a reload may re-seat main RAM */
|
|
|
|
fprintf(stderr, "synthetic disc: session2 LBA %u, %u sectors, "
|
|
"payload LBA %u\n", s2FirstLBA, discSectors, payloadLBA);
|
|
|
|
RUN_TEST(synth_disc_lba_addressing);
|
|
RUN_TEST(long_rounded_tail_holds_real_disc_bytes);
|
|
RUN_TEST(delivery_is_streamed_not_instant);
|
|
RUN_TEST(inflight_stream_survives_savestate_round_trip);
|
|
RUN_TEST(arming_over_inflight_drops_tail_and_completion);
|
|
|
|
/* Fresh boot for the end-to-end run so the 68K starts at the stub. */
|
|
p_unload_game();
|
|
if (!load_disc(cue))
|
|
{
|
|
fprintf(stderr, " FAIL synthetic disc did not reload for e2e\n");
|
|
rc = 1;
|
|
goto out;
|
|
}
|
|
ram = C.GetRamPtr();
|
|
RUN_TEST(stub_driven_read_end_to_end);
|
|
|
|
rc = TEST_REPORT();
|
|
out:
|
|
p_unload_game();
|
|
C.retro_deinit();
|
|
dlclose(C.handle);
|
|
|
|
scrub_scratch(base);
|
|
|
|
return rc;
|
|
}
|