Files
Joseph MattielloandClaude Opus 5.5 42f1c488da test(cd): synthetic CHD fixture with virtual pregaps (#774)
Every committed CHD fixture was PREGAP 0, so the virtual-pregap path #755
fixed had no coverage. synth_jagcd_vpregap.chd has three session-2 tracks
with 2/3/5-sector INDEX 00 runs that the pinned chdman records as VAUDIO
pregaps counted inside FRAMES (the Myst (USA) layout). Data sectors carry
per-track, per-sector tags so a misplaced read fails on content.

test_cd_chd checks each track's TOC MSF, that every pregap sector reads
silence, and that every INDEX 01 sector reads its own tag. It passes on
develop and fails on f28ade2^1, reading silence at track 3's INDEX 01.
jagcd-chd-check must exit 2 on the fixture, in make test and in CI.

Refs #774

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 23:36:04 -04:00

428 lines
13 KiB
C

/*
* test_cd_chd.c -- CHD session gate (issue #322) and CHSE track-position
* conflation (issue #476)
*
* good : test/roms/synth_jagcd.chd (CHSE, two sessions) must load
* with session-2 INDEX 01 at LBA 11404 (4-sector session 1
* + synthesized 11400-sector gap, PREGAP 0).
* nosession : test/roms/synth_jagcd_nosession.chd must be refused by
* ParseCHD (log contains "no session metadata (CHSE)"),
* not merely fail later at HLE extract.
* multi : test/roms/synth_jagcd_multi.chd has THREE tracks, with
* session 1 carrying two of them (track 1, track 2) and
* session 2 starting at track 3. CHD's CHSE metadata is
* indexed among CHSE entries themselves (index 0 = the 1st
* CHSE entry anywhere in the file, 1 = the 2nd), not by
* track position -- a parser that queries CHSE at the same
* index as its track loop counter only gets the right
* answer when session 1 has exactly one track. This fixture
* pins track 2 to session 1 (not session 2) and the boot
* stub to track 3.
* vpregap : test/roms/synth_jagcd_vpregap.chd has three session-2
* tracks whose INDEX 00 runs (2, 3 and 5 sectors) chdman
* records as VAUDIO virtual pregaps COUNTED INSIDE the
* track's FRAMES (issue #774, the #754/#755 case). Before
* #755 ParseCHD added PREGAP to FRAMES a second time (every
* later track shifted by the sum of the earlier pregaps) and
* read INDEX 01 from the head of the pregap. Every tagged
* sector names its own track and sector, so a read from the
* wrong place fails on content, not just on silence.
* Source: test/tools/gen_synth_jagcd_vpregap.py.
*
* Fixtures are committed uncompressed CD CHDs. If one is missing, SKIP
* rather than fail a checkout that forgot to git-add the files. There
* is no in-test rebuild path (CI/Homebrew chdman cannot write CHSE).
*
* Build: make TEST_EXPORTS=1 test/test_cd_chd
*/
#include "cd_assertions.h"
#include "../libretro-common/include/libretro.h"
#include <stdarg.h>
#include <unistd.h>
#include <sys/stat.h>
static struct vj_core C;
static char g_log[8192];
static bool file_exists(const char *path)
{
struct stat st;
return stat(path, &st) == 0 && st.st_size > 0;
}
static void test_log(enum retro_log_level level, const char *fmt, ...)
{
va_list ap;
size_t n;
int wrote;
(void)level;
n = strlen(g_log);
if (n + 1 >= sizeof(g_log))
return;
va_start(ap, fmt);
wrote = vsnprintf(g_log + n, sizeof(g_log) - n, fmt, ap);
va_end(ap);
(void)wrote;
}
static bool cd_load_game(const char *path)
{
struct retro_game_info info;
bool (*p_retro_load_game)(const struct retro_game_info *);
memset(&info, 0, sizeof(info));
info.path = path;
p_retro_load_game = (bool (*)(const struct retro_game_info *))
dlsym(C.handle, "retro_load_game");
if (!p_retro_load_game) return false;
g_log[0] = '\0';
return p_retro_load_game(&info);
}
static void cd_unload_game(void)
{
void (*p)(void) = (void (*)(void))dlsym(C.handle, "retro_unload_game");
if (p) p();
}
static uint32_t cd_num_sessions(void)
{
uint32_t (*p)(void) = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetNumSessions");
if (!p) return 0;
return p();
}
static uint32_t cd_session2_lba(void)
{
uint32_t (*p)(void) = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetSession2FirstTrackLBA");
if (!p) return 0;
return p();
}
static uint32_t cd_num_tracks(void)
{
uint32_t (*p)(void) = (uint32_t (*)(void))dlsym(C.handle, "CDIntfGetNumTracks");
if (!p) return 0;
return p();
}
static uint8_t cd_track_session(uint32_t track)
{
uint8_t (*p)(uint32_t) = (uint8_t (*)(uint32_t))dlsym(C.handle, "CDIntfGetTrackSession");
if (!p) return 0;
return p(track);
}
static bool cd_read_block(uint32_t lba, uint8_t *buf)
{
bool (*p)(uint32_t, uint8_t *) =
(bool (*)(uint32_t, uint8_t *))dlsym(C.handle, "CDIntfReadBlock");
if (!p) return false;
return p(lba, buf);
}
static bool cd_last_virtual_pregap(void)
{
bool (*p)(void) = (bool (*)(void))dlsym(C.handle, "CDIntfLastReadWasVirtualPregap");
if (!p) return false;
return p();
}
static void cd_clear_virtual_pregap(void)
{
void (*p)(void) = (void (*)(void))dlsym(C.handle, "CDIntfClearLastReadVirtualPregap");
if (p) p();
}
static bool cd_extract_stub(uint8_t *buf, uint32_t bufsz, uint32_t *addr, uint32_t *len)
{
bool (*p)(uint8_t *, uint32_t, uint32_t *, uint32_t *) =
(bool (*)(uint8_t *, uint32_t, uint32_t *, uint32_t *))
dlsym(C.handle, "CDIntfExtractBootStub");
if (!p) return false;
return p(buf, bufsz, addr, len);
}
static void swap16(uint8_t *buf, size_t n)
{
size_t i;
uint8_t t;
for (i = 0; i + 1 < n; i += 2) {
t = buf[i];
buf[i] = buf[i + 1];
buf[i + 1] = t;
}
}
static bool env_cb(unsigned cmd, void *data)
{
if (cmd == RETRO_ENVIRONMENT_GET_LOG_INTERFACE) {
struct retro_log_callback *cb = (struct retro_log_callback *)data;
cb->log = test_log;
return true;
}
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;
}
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; }
TEST(chd_without_chse_is_refused)
{
const char *fix = "test/roms/synth_jagcd_nosession.chd";
if (!file_exists(fix)) {
SKIP_TEST(chd_without_chse_is_refused, "missing test/roms/synth_jagcd_nosession.chd");
return;
}
ASSERT_TRUE(!cd_load_game(fix));
ASSERT_TRUE(strstr(g_log, "no session metadata (CHSE)") != NULL);
cd_unload_game();
}
TEST(chd_with_chse_has_two_sessions)
{
const char *fix = "test/roms/synth_jagcd.chd";
if (!file_exists(fix)) {
SKIP_TEST(chd_with_chse_has_two_sessions, "missing test/roms/synth_jagcd.chd");
return;
}
ASSERT_TRUE(cd_load_game(fix));
ASSERT_EQ_U32(cd_num_sessions(), 2u);
ASSERT_EQ_U32(cd_num_tracks(), 2u);
ASSERT_EQ(cd_track_session(1), 1);
ASSERT_EQ(cd_track_session(2), 2);
/* 4-sector session 1 + synthesized 11400-sector gap, PREGAP 0. */
ASSERT_EQ_U32(cd_session2_lba(), 11404u);
cd_unload_game();
}
TEST(chd_gap_and_session1_are_silence)
{
const char *fix = "test/roms/synth_jagcd.chd";
uint8_t buf[2352];
size_t i;
int nonzero;
if (!file_exists(fix)) {
SKIP_TEST(chd_gap_and_session1_are_silence, "missing test/roms/synth_jagcd.chd");
return;
}
ASSERT_TRUE(cd_load_game(fix));
ASSERT_TRUE(cd_read_block(0, buf));
nonzero = 0;
for (i = 0; i < sizeof(buf); i++) {
if (buf[i]) { nonzero = 1; break; }
}
ASSERT_FALSE(nonzero);
cd_clear_virtual_pregap();
ASSERT_TRUE(cd_read_block(100, buf));
ASSERT_TRUE(cd_last_virtual_pregap());
nonzero = 0;
for (i = 0; i < sizeof(buf); i++) {
if (buf[i]) { nonzero = 1; break; }
}
ASSERT_FALSE(nonzero);
cd_unload_game();
}
TEST(chd_session2_header_and_extract)
{
const char *fix = "test/roms/synth_jagcd.chd";
static const char magic[32] = "ATARI APPROVED DATA HEADER ATRI ";
uint8_t raw[2352];
uint8_t stub[0x800];
uint32_t addr, len;
if (!file_exists(fix)) {
SKIP_TEST(chd_session2_header_and_extract, "missing test/roms/synth_jagcd.chd");
return;
}
ASSERT_TRUE(cd_load_game(fix));
ASSERT_TRUE(cd_read_block(11404u, raw));
/* ReadBlock returns CUE/BIN I2S order; unswap to host/header space. */
swap16(raw, sizeof(raw));
ASSERT_TRUE(memcmp(raw + 0x42, magic, 32) == 0);
addr = 0;
len = 0;
ASSERT_TRUE(cd_extract_stub(stub, sizeof(stub), &addr, &len));
ASSERT_TRUE(len > 0);
ASSERT_TRUE(len <= sizeof(stub));
cd_unload_game();
}
TEST(chd_multi_track_session1_is_not_flipped_early)
{
const char *fix = "test/roms/synth_jagcd_multi.chd";
uint32_t addr, len;
if (!file_exists(fix)) {
SKIP_TEST(chd_multi_track_session1_is_not_flipped_early,
"missing test/roms/synth_jagcd_multi.chd");
return;
}
ASSERT_TRUE(cd_load_game(fix));
ASSERT_EQ_U32(cd_num_sessions(), 2u);
ASSERT_EQ_U32(cd_num_tracks(), 3u);
/* The regression: track 2 must stay session 1 (CHSE's 2nd entry
* belongs to track 3, not track 2 -- see the file header comment). */
ASSERT_EQ(cd_track_session(1), 1);
ASSERT_EQ(cd_track_session(2), 1);
ASSERT_EQ(cd_track_session(3), 2);
/* track1 (4) + track2 (4) + synthesized 11400-sector gap, PREGAP 0. */
ASSERT_EQ_U32(cd_session2_lba(), 11408u);
{
uint8_t stub[0x800];
addr = 0;
len = 0;
ASSERT_TRUE(cd_extract_stub(stub, sizeof(stub), &addr, &len));
ASSERT_EQ_U32(addr, 0x00080000u);
ASSERT_EQ_U32(len, 0x100u);
}
cd_unload_game();
}
/* Track layout of synth_jagcd_vpregap.chd, in disc LBAs: track 1 (4) +
* synthesized 11400-sector session gap + track 2 (4) puts track 3's
* pregap at 11408. Each later track starts where the previous one's
* FRAMES (pregap + data) end. */
#define VPG_DATA_SECTORS 4u
static const struct {
uint32_t track, startLBA, pregap;
} vpg_tracks[] = {
{ 3u, 11408u, 2u },
{ 4u, 11414u, 3u },
{ 5u, 11421u, 5u }
};
/* Mirrors tag_sector() in test/tools/gen_synth_jagcd_vpregap.py. */
static void vpg_tag_sector(uint32_t track, uint32_t sector, uint8_t *out)
{
char tag[17];
size_t i;
snprintf(tag, sizeof(tag), "T%02uS%02u vpregap ",
(unsigned)track, (unsigned)sector);
for (i = 0; i < 2352u; i += 16u)
memcpy(out + i, tag, 16u);
}
TEST(chd_virtual_pregaps_land_on_index01)
{
const char *fix = "test/roms/synth_jagcd_vpregap.chd";
uint8_t got[2352];
uint8_t want[2352];
uint8_t zero[2352];
uint32_t t, s, dataLBA, absLBA;
uint8_t (*p_track_info)(uint32_t, uint32_t);
if (!file_exists(fix)) {
SKIP_TEST(chd_virtual_pregaps_land_on_index01,
"missing test/roms/synth_jagcd_vpregap.chd");
return;
}
p_track_info = (uint8_t (*)(uint32_t, uint32_t))
dlsym(C.handle, "CDIntfGetTrackInfo");
ASSERT_TRUE(p_track_info != NULL);
memset(zero, 0, sizeof(zero));
ASSERT_TRUE(cd_load_game(fix));
ASSERT_EQ_U32(cd_num_sessions(), 2u);
ASSERT_EQ_U32(cd_num_tracks(), 5u);
for (t = 0; t < sizeof(vpg_tracks) / sizeof(vpg_tracks[0]); t++) {
dataLBA = vpg_tracks[t].startLBA + vpg_tracks[t].pregap;
ASSERT_EQ(cd_track_session(vpg_tracks[t].track), 2);
/* TOC: INDEX 01 as absolute MSF (150-frame lead-in). */
absLBA = dataLBA + 150u;
ASSERT_EQ_U32(p_track_info(vpg_tracks[t].track, 0), absLBA / 4500u);
ASSERT_EQ_U32(p_track_info(vpg_tracks[t].track, 1), (absLBA / 75u) % 60u);
ASSERT_EQ_U32(p_track_info(vpg_tracks[t].track, 2), absLBA % 75u);
/* Pregap reads silence, first through last sector. */
for (s = 0; s < vpg_tracks[t].pregap; s++) {
ASSERT_TRUE(cd_read_block(vpg_tracks[t].startLBA + s, got));
ASSERT_TRUE(memcmp(got, zero, sizeof(got)) == 0);
}
/* INDEX 01 onward is this track's own data, sector for sector,
* through the last one (catches a wrong track length too). */
for (s = 0; s < VPG_DATA_SECTORS; s++) {
ASSERT_TRUE(cd_read_block(dataLBA + s, got));
vpg_tag_sector(vpg_tracks[t].track, s, want);
if (memcmp(got, want, sizeof(got)) != 0)
fprintf(stderr, " track %u sector %u at LBA %u read \"%.16s\"\n",
(unsigned)vpg_tracks[t].track, (unsigned)s,
(unsigned)(dataLBA + s), (const char *)got);
ASSERT_TRUE(memcmp(got, want, sizeof(got)) == 0);
}
}
cd_unload_game();
}
int main(int argc, char *argv[])
{
(void)argc; (void)argv;
TEST_INIT("CD CHD session gate");
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();
RUN_TEST(chd_without_chse_is_refused);
RUN_TEST(chd_with_chse_has_two_sessions);
RUN_TEST(chd_gap_and_session1_are_silence);
RUN_TEST(chd_session2_header_and_extract);
RUN_TEST(chd_multi_track_session1_is_not_flipped_early);
RUN_TEST(chd_virtual_pregaps_land_on_index01);
{
void (*p)(void) = (void (*)(void))dlsym(C.handle, "retro_deinit");
if (p) p();
}
if (C.handle) dlclose(C.handle);
return TEST_REPORT();
}