From eefc915ceed19c545bcfb2b50a37851803d0f475 Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:16:31 -0700 Subject: [PATCH 1/3] towers for editor --- PROJECTS/ROLLER/loadtrak.c | 2 +- PROJECTS/ROLLER/tower.c | 8 +- PROJECTS/ROLLER/tower.h | 12 +- tests/editor_reload_soak_acceptance.c | 241 +++++++++++++++++++++++++- tests/test_e7_s1_tower_limit.py | 58 +++++++ 5 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 tests/test_e7_s1_tower_limit.py diff --git a/PROJECTS/ROLLER/loadtrak.c b/PROJECTS/ROLLER/loadtrak.c index a93dc5af..306f59d6 100644 --- a/PROJECTS/ROLLER/loadtrak.c +++ b/PROJECTS/ROLLER/loadtrak.c @@ -1190,7 +1190,7 @@ static eRollerEdResult loadtrack_internal( ++NumBuildings; p_fBuildingAnglesBase = p_fBuildingAngles + 2; p_fBuildingAngles[1] = (float)iSignRoll; - } else { + } else if (NumTowers < MAX_TOWERS) { *pTowerBasePtr++ = iChunkIdx; pTowerBase = pTowerBasePtr; *pTowerBasePtr = iSignHOffset; diff --git a/PROJECTS/ROLLER/tower.c b/PROJECTS/ROLLER/tower.c index ccb1bc3e..ac563f82 100644 --- a/PROJECTS/ROLLER/tower.c +++ b/PROJECTS/ROLLER/tower.c @@ -9,10 +9,10 @@ //------------------------------------------------------------------------------------------------- int TowerSect[MAX_TRACK_CHUNKS]; //001A1FA0 -float TowerX[32]; //001A2770 -float TowerY[32]; //001A27F0 -float TowerZ[32]; //001A2870 -tTowerBase TowerBase[32]; //001A28F0 +float TowerX[MAX_TOWERS]; //001A2770 +float TowerY[MAX_TOWERS]; //001A27F0 +float TowerZ[MAX_TOWERS]; //001A2870 +tTowerBase TowerBase[MAX_TOWERS]; //001A28F0 tPolyParams TowerPol; //001A2B70 int NumTowers; //001A2B9C diff --git a/PROJECTS/ROLLER/tower.h b/PROJECTS/ROLLER/tower.h index cc997f90..1256b2c5 100644 --- a/PROJECTS/ROLLER/tower.h +++ b/PROJECTS/ROLLER/tower.h @@ -4,6 +4,8 @@ #include "polyf.h" //------------------------------------------------------------------------------------------------- +#define MAX_TOWERS 32 + typedef struct { int iChunkIdx; @@ -16,10 +18,10 @@ typedef struct //------------------------------------------------------------------------------------------------- extern int TowerSect[MAX_TRACK_CHUNKS]; -extern float TowerX[32]; -extern float TowerY[32]; -extern float TowerZ[32]; -extern tTowerBase TowerBase[32]; +extern float TowerX[MAX_TOWERS]; +extern float TowerY[MAX_TOWERS]; +extern float TowerZ[MAX_TOWERS]; +extern tTowerBase TowerBase[MAX_TOWERS]; extern tPolyParams TowerPol; extern int NumTowers; @@ -29,4 +31,4 @@ void InitTowers(); void DrawTower(int iTowerIdx, uint8 *pScrBuf); //------------------------------------------------------------------------------------------------- -#endif \ No newline at end of file +#endif diff --git a/tests/editor_reload_soak_acceptance.c b/tests/editor_reload_soak_acceptance.c index 8100e945..fee0f5ae 100644 --- a/tests/editor_reload_soak_acceptance.c +++ b/tests/editor_reload_soak_acceptance.c @@ -41,10 +41,12 @@ #include "3d.h" #include "editor_api.h" +#include "editor_track_loader.h" #include "game_render.h" #include "graphics.h" #include "horizon.h" #include "scene_render.h" +#include "tower.h" #define SDL_MAIN_HANDLED 1 #include @@ -63,6 +65,7 @@ enum { SOAK_BUFFER_SIZE = SOAK_ROW_PITCH * SOAK_HEIGHT, SOAK_DEFAULT_CYCLES = 250, SOAK_MALFORMED_COUNT = 4, + SOAK_TOWER_INPUT_COUNT = MAX_TOWERS + 2, /* Full geometry extraction is two whole-track traversals, so it rides * along periodically rather than every cycle. */ SOAK_FILL_INTERVAL = 8, @@ -76,6 +79,7 @@ typedef struct char szScratchDir[1024]; int iCycles; char szMalformed[SOAK_MALFORMED_COUNT][1024]; + char szTowerLimitTrack[1024]; char szError[512]; int iResult; @@ -313,6 +317,238 @@ static int soak_build_malformed_inputs(tSoakContext *pContext) return bOk; } +static int soak_take_text_line(const uint8_t **ppbyCursor, + const uint8_t *pbyEnd, + const uint8_t **ppbyLine, + size_t *puiLineLength) +{ + const uint8_t *pbyCursor = *ppbyCursor; + const uint8_t *pbyLine = pbyCursor; + + if (pbyCursor >= pbyEnd) + return 0; + while (pbyCursor < pbyEnd && *pbyCursor != '\r' + && *pbyCursor != '\n' && *pbyCursor != 0x1au) + ++pbyCursor; + *ppbyLine = pbyLine; + *puiLineLength = (size_t)(pbyCursor - pbyLine); + while (pbyCursor < pbyEnd + && (*pbyCursor == '\r' || *pbyCursor == '\n')) + ++pbyCursor; + *ppbyCursor = pbyCursor; + return 1; +} + +static int soak_take_data_line(const uint8_t **ppbyCursor, + const uint8_t *pbyEnd, + const uint8_t **ppbyLine, + size_t *puiLineLength) +{ + while (soak_take_text_line(ppbyCursor, pbyEnd, + ppbyLine, puiLineLength)) { + size_t uiFirst = 0u; + + while (uiFirst < *puiLineLength + && ((*ppbyLine)[uiFirst] == ' ' + || (*ppbyLine)[uiFirst] == '\t')) + ++uiFirst; + if (uiFirst == *puiLineLength || (*ppbyLine)[uiFirst] == ';' + || (uiFirst + 1u < *puiLineLength + && (*ppbyLine)[uiFirst] == '/' + && (*ppbyLine)[uiFirst + 1u] == '/')) + continue; + return 1; + } + return 0; +} + +static int soak_write_text_line(FILE *pFile, const uint8_t *pbyLine, + size_t uiLineLength) +{ + return fwrite(pbyLine, 1u, uiLineLength, pFile) == uiLineLength + && fputs("\r\n", pFile) != EOF; +} + +static int soak_write_tower_surface_record(FILE *pFile, + const uint8_t *pbyLine, + size_t uiLineLength, + int iChunk) +{ + const uint8_t *apbyField[18]; + size_t auiFieldLength[18]; + size_t uiCursor = 0u; + + for (int iField = 0; iField < 18; ++iField) { + size_t uiStart; + + while (uiCursor < uiLineLength + && (pbyLine[uiCursor] == ' ' + || pbyLine[uiCursor] == '\t')) + ++uiCursor; + uiStart = uiCursor; + while (uiCursor < uiLineLength + && pbyLine[uiCursor] != ' ' + && pbyLine[uiCursor] != '\t') + ++uiCursor; + if (uiCursor == uiStart) + return 0; + apbyField[iField] = pbyLine + uiStart; + auiFieldLength[iField] = uiCursor - uiStart; + } + for (int iField = 0; iField < 18; ++iField) { + if (iField != 0 && fputc(' ', pFile) == EOF) + return 0; + if (iField == 12) { + if (fputs("256", pFile) == EOF) + return 0; + } else if (iField == 13) { + if (fprintf(pFile, "%d", iChunk) < 0) + return 0; + } else if (iField == 14) { + if (fprintf(pFile, "%d", -iChunk) < 0) + return 0; + } else if (fwrite(apbyField[iField], 1u, auiFieldLength[iField], pFile) + != auiFieldLength[iField]) { + return 0; + } + } + return fputs("\r\n", pFile) != EOF; +} + +/* + * E7-S1. Build this at runtime so the Valgrind soak exercises the real + * staged-loader and facade path with more authored towers than the legacy + * fixed table can hold. The two tower-authored chunks past capacity remain + * valid track data; only their decoded runtime towers are omitted. + */ +static int soak_build_tower_limit_input(tSoakContext *pContext) +{ + tEdTrackStage Stage; + char szStageError[256]; + const uint8_t *pbyCursor; + const uint8_t *pbyEnd; + const uint8_t *pbyLine; + size_t uiLineLength; + FILE *pFile = NULL; + int bOk = 1; + + ed_track_stage_init(&Stage); + if (ed_track_file_stage(pContext->szTrackPath, &Stage, + szStageError, sizeof(szStageError)) + != ED_TRACK_LOAD_OK) { + soak_fail(pContext, "could not stage tower-limit source: %s", + szStageError); + return 0; + } + if (Stage.uiChunkCount < SOAK_TOWER_INPUT_COUNT) { + soak_fail(pContext, + "tower-limit source has only %u chunks; need at least %d", + Stage.uiChunkCount, SOAK_TOWER_INPUT_COUNT); + ed_track_stage_dispose(&Stage); + return 0; + } + + snprintf(pContext->szTowerLimitTrack, + sizeof(pContext->szTowerLimitTrack), + "%s/e7_s1_tower_limit.trk", pContext->szScratchDir); + pFile = fopen(pContext->szTowerLimitTrack, "wb"); + if (!pFile) { + soak_fail(pContext, "could not create %s", + pContext->szTowerLimitTrack); + ed_track_stage_dispose(&Stage); + return 0; + } + + pbyCursor = Stage.pbyData; + pbyEnd = Stage.pbyData + Stage.uiDataLength; + if (!soak_take_data_line(&pbyCursor, pbyEnd, + &pbyLine, &uiLineLength)) { + bOk = 0; + } else { + size_t uiLeadingWhitespace = 0u; + + while (uiLeadingWhitespace < uiLineLength + && (pbyLine[uiLeadingWhitespace] == ' ' + || pbyLine[uiLeadingWhitespace] == '\t')) + ++uiLeadingWhitespace; + bOk = fputs(" ", pFile) != EOF + && soak_write_text_line( + pFile, pbyLine + uiLeadingWhitespace, + uiLineLength - uiLeadingWhitespace); + } + for (uint32_t uiChunk = 0u; uiChunk < Stage.uiChunkCount && bOk; + ++uiChunk) { + bOk = soak_take_data_line(&pbyCursor, pbyEnd, + &pbyLine, &uiLineLength) + && soak_write_text_line(pFile, pbyLine, uiLineLength) + && soak_take_data_line(&pbyCursor, pbyEnd, + &pbyLine, &uiLineLength); + if (bOk && uiChunk < SOAK_TOWER_INPUT_COUNT) { + bOk = soak_write_tower_surface_record( + pFile, pbyLine, uiLineLength, (int)uiChunk); + } else if (bOk) { + bOk = soak_write_text_line(pFile, pbyLine, uiLineLength); + } + bOk = bOk + && soak_take_data_line(&pbyCursor, pbyEnd, + &pbyLine, &uiLineLength) + && soak_write_text_line(pFile, pbyLine, uiLineLength); + } + if (bOk && fwrite(pbyCursor, 1u, (size_t)(pbyEnd - pbyCursor), pFile) + != (size_t)(pbyEnd - pbyCursor)) + bOk = 0; + if (fclose(pFile) != 0) + bOk = 0; + ed_track_stage_dispose(&Stage); + if (!bOk) { + soak_fail(pContext, "could not write %s", + pContext->szTowerLimitTrack); + return 0; + } + return 1; +} + +static int soak_verify_tower_limit(tSoakContext *pContext) +{ + if (RollerEd_LoadTrackFile(pContext->szTowerLimitTrack, + pContext->szAssetRoot) + != ROLLER_ED_RESULT_OK) { + soak_fail(pContext, "tower-limit track load failed: %s", + RollerEd_GetLastError()); + return 0; + } + if (NumTowers != MAX_TOWERS) { + soak_fail(pContext, "tower-limit track decoded %d towers; expected %d", + NumTowers, MAX_TOWERS); + return 0; + } + for (int iTower = 0; iTower < MAX_TOWERS; ++iTower) { + if (TowerBase[iTower].iChunkIdx != iTower + || TowerBase[iTower].iHOffset != iTower + || TowerBase[iTower].iVOffset != -iTower + || TowerBase[iTower].iEnabled != -1 + || TowerBase[iTower].iTowerType != 0 + || TowerSect[iTower] != iTower) { + soak_fail(pContext, + "tower %d did not preserve its decoded fields", + iTower); + return 0; + } + } + for (int iChunk = MAX_TOWERS; + iChunk < SOAK_TOWER_INPUT_COUNT; ++iChunk) { + if (TowerSect[iChunk] != -1) { + soak_fail(pContext, + "overflow tower on chunk %d entered the runtime table", + iChunk); + return 0; + } + } + printf(" E7-S1: retained the first %d of %d authored towers\n", + NumTowers, SOAK_TOWER_INPUT_COUNT); + return 1; +} + static int soak_query(tSoakContext *pContext, tEdGeometrySizes *pSizes, const char *szPhase) { @@ -735,7 +971,8 @@ static int SDLCALL soak_worker(void *pUserData) uint32_t uiAvailable; int iGPUCycles; - if (!soak_build_malformed_inputs(pContext)) + if (!soak_build_malformed_inputs(pContext) + || !soak_build_tower_limit_input(pContext)) return pContext->iResult; pContext->pPixels = (uint8_t *)malloc(SOAK_BUFFER_SIZE); @@ -748,6 +985,8 @@ static int SDLCALL soak_worker(void *pUserData) soak_fail(pContext, "RollerEd_Init failed: %s", RollerEd_GetLastError()); goto done; } + if (!soak_verify_tower_limit(pContext)) + goto shutdown; uiAvailable = RollerEd_GetAvailableRenderers(); if (!soak_run_phase(pContext, ROLLER_ED_RENDERER_SOFTWARE, diff --git a/tests/test_e7_s1_tower_limit.py b/tests/test_e7_s1_tower_limit.py new file mode 100644 index 00000000..fd8a487d --- /dev/null +++ b/tests/test_e7_s1_tower_limit.py @@ -0,0 +1,58 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ROLLER = ROOT / "PROJECTS" / "ROLLER" + + +class TowerLimitTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.header = (ROLLER / "tower.h").read_text(encoding="utf-8") + cls.source = (ROLLER / "tower.c").read_text(encoding="utf-8") + cls.loader = (ROLLER / "loadtrak.c").read_text(encoding="utf-8") + cls.soak = (ROOT / "tests" / "editor_reload_soak_acceptance.c").read_text( + encoding="utf-8" + ) + + def test_one_constant_sizes_every_tower_table(self) -> None: + self.assertIn("#define MAX_TOWERS 32", self.header) + for declaration in ( + "TowerX[MAX_TOWERS]", + "TowerY[MAX_TOWERS]", + "TowerZ[MAX_TOWERS]", + "TowerBase[MAX_TOWERS]", + ): + self.assertIn(declaration, self.header) + self.assertIn(declaration, self.source) + + def test_loader_checks_the_limit_before_its_first_write(self) -> None: + guard = self.loader.index("else if (NumTowers < MAX_TOWERS) {") + first_write = self.loader.index("*pTowerBasePtr++ = iChunkIdx;", guard) + following_chunk_work = self.loader.index("rotatepoint(", guard) + guarded_tower_decode = self.loader[guard:following_chunk_work] + + self.assertLess(guard, first_write) + self.assertIn("++NumTowers;", guarded_tower_decode) + self.assertEqual(self.loader.count("++NumTowers;"), 1) + + def test_sanitizer_soak_loads_more_than_the_table_can_hold(self) -> None: + self.assertIn("SOAK_TOWER_INPUT_COUNT = MAX_TOWERS + 2", self.soak) + self.assertIn("RollerEd_LoadTrackFile(pContext->szTowerLimitTrack", self.soak) + self.assertIn("NumTowers != MAX_TOWERS", self.soak) + self.assertIn("TowerSect[iChunk] != -1", self.soak) + + def test_soak_checks_every_retained_tower_field(self) -> None: + for field in ( + "iChunkIdx", + "iHOffset", + "iVOffset", + "iEnabled", + "iTowerType", + ): + self.assertIn(f"TowerBase[iTower].{field}", self.soak) + + +if __name__ == "__main__": + unittest.main() From e82431c40a6cab2a8d2bbaf28c04123792e78a32 Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:35:28 -0700 Subject: [PATCH 2/3] facade tower query --- PROJECTS/ROLLER/editor_api.c | 61 +++++++++++ PROJECTS/ROLLER/editor_api.h | 29 ++++++ PROJECTS/ROLLER/editor_legacy_scene.c | 24 +++++ PROJECTS/ROLLER/editor_legacy_scene.h | 5 + tests/editor_api_cpp_test.cpp | 3 + tests/editor_api_lifecycle_test.c | 118 ++++++++++++++++++++++ tests/editor_reload_soak_acceptance.c | 52 +++++++++- tests/test_e7_s2_tower_query.py | 140 ++++++++++++++++++++++++++ tests/test_editor_api_contract.py | 2 + 9 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 tests/test_e7_s2_tower_query.py diff --git a/PROJECTS/ROLLER/editor_api.c b/PROJECTS/ROLLER/editor_api.c index be92bc8b..effc8a9a 100644 --- a/PROJECTS/ROLLER/editor_api.c +++ b/PROJECTS/ROLLER/editor_api.c @@ -734,6 +734,67 @@ eRollerEdResult ROLLER_ED_CALL RollerEd_QueryGeometrySizes( return ROLLER_ED_RESULT_OK; } +eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTowerCount( + uint32_t *puiCountOut) +{ + eRollerEdResult eResult = roller_ed_require_worker(); + uint32_t uiCount; + + if (eResult != ROLLER_ED_RESULT_OK) + return eResult; + if (!puiCountOut) { + roller_ed_set_error("RollerEd_QueryTowerCount requires puiCountOut"); + return ROLLER_ED_RESULT_INVALID_ARGUMENT; + } + if (s_eSceneState != ROLLER_ED_SCENE_READY) { + roller_ed_set_error("there is no tower scene"); + return ROLLER_ED_RESULT_NO_SCENE; + } + + uiCount = roller_ed_legacy_scene_tower_count(); + *puiCountOut = uiCount; + return ROLLER_ED_RESULT_OK; +} + +eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut) +{ + eRollerEdResult eResult = roller_ed_require_worker(); + tEdTowerInfo Info; + uint32_t uiCount; + + if (eResult != ROLLER_ED_RESULT_OK) + return eResult; + if (!pInfoOut) { + roller_ed_set_error("RollerEd_QueryTower requires pInfoOut"); + return ROLLER_ED_RESULT_INVALID_ARGUMENT; + } + eResult = roller_ed_validate_struct( + pInfoOut->uiStructSize, pInfoOut->uiVersion, + ROLLER_ED_TOWER_INFO_VERSION, sizeof(*pInfoOut), + "tEdTowerInfo"); + if (eResult != ROLLER_ED_RESULT_OK) + return eResult; + if (s_eSceneState != ROLLER_ED_SCENE_READY) { + roller_ed_set_error("there is no tower scene"); + return ROLLER_ED_RESULT_NO_SCENE; + } + + uiCount = roller_ed_legacy_scene_tower_count(); + if (uiTowerIndex >= uiCount) { + roller_ed_set_error("tower index %u is not below tower count %u", + uiTowerIndex, uiCount); + return ROLLER_ED_RESULT_INVALID_ARGUMENT; + } + + memset(&Info, 0, sizeof(Info)); + Info.uiStructSize = sizeof(Info); + Info.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + roller_ed_legacy_scene_query_tower(uiTowerIndex, &Info); + *pInfoOut = Info; + return ROLLER_ED_RESULT_OK; +} + eRollerEdResult ROLLER_ED_CALL RollerEd_FillGeometry( uint32_t uiExpectedGeometryEpoch, tEdVertex *pVerts, uint32_t uiVertexCapacity, diff --git a/PROJECTS/ROLLER/editor_api.h b/PROJECTS/ROLLER/editor_api.h index b348b451..84d71e60 100644 --- a/PROJECTS/ROLLER/editor_api.h +++ b/PROJECTS/ROLLER/editor_api.h @@ -279,6 +279,7 @@ enum #define ROLLER_ED_OVERLAY_STATE_VERSION 3u #define ROLLER_ED_REFERENCE_MESH_VERSION 1u #define ROLLER_ED_GEOMETRY_SIZES_VERSION 1u +#define ROLLER_ED_TOWER_INFO_VERSION 1u #if defined(_MSC_VER) || defined(__GNUC__) || defined(__clang__) # pragma pack(push, 8) @@ -421,6 +422,15 @@ typedef struct uint32_t uiMaterialStride; } tEdGeometrySizes; +typedef struct +{ + uint32_t uiStructSize; + uint32_t uiVersion; + uint32_t uiChunkId; + float fWorldPosition[3]; + float fAnchorPosition[3]; +} tEdTowerInfo; + #if defined(_MSC_VER) || defined(__GNUC__) || defined(__clang__) # pragma pack(pop) #endif @@ -524,6 +534,15 @@ ROLLER_ED_STATIC_ASSERT(offsetof(tEdGeometrySizes, uiGeometryEpoch) == 8u, "geometry epoch offset"); ROLLER_ED_STATIC_ASSERT(offsetof(tEdGeometrySizes, uiVertexStride) == 36u, "geometry stride offset"); +ROLLER_ED_STATIC_ASSERT(sizeof(tEdTowerInfo) == 36u, "tower info size"); +ROLLER_ED_STATIC_ASSERT(ROLLER_ED_ALIGNOF(tEdTowerInfo) == 4u, + "tower info alignment"); +ROLLER_ED_STATIC_ASSERT(offsetof(tEdTowerInfo, uiChunkId) == 8u, + "tower chunk offset"); +ROLLER_ED_STATIC_ASSERT(offsetof(tEdTowerInfo, fWorldPosition) == 12u, + "tower world position offset"); +ROLLER_ED_STATIC_ASSERT(offsetof(tEdTowerInfo, fAnchorPosition) == 24u, + "tower anchor position offset"); ROLLER_ED_STATIC_ASSERT(ROLLER_ED_ALIGNOF(tEdVertex) == 4u, "vertex alignment"); ROLLER_ED_STATIC_ASSERT(ROLLER_ED_ALIGNOF(tEdPrimitive) == 4u, "primitive alignment"); ROLLER_ED_STATIC_ASSERT(ROLLER_ED_ALIGNOF(tEdMaterial) == 4u, "material alignment"); @@ -608,6 +627,16 @@ ROLLER_ED_API eRollerEdResult ROLLER_ED_CALL RollerEd_SetOverlayState( ROLLER_ED_API eRollerEdResult ROLLER_ED_CALL RollerEd_SetReferenceMesh( const tEdReferenceMesh *pMesh); +/* + * Tower results are copied from the committed scene during the call; no + * pointer is retained. Both calls require a READY scene on the render worker. + * pInfoOut must carry sizeof(tEdTowerInfo) and TOWER_INFO_VERSION on entry. + */ +ROLLER_ED_API eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTowerCount( + uint32_t *puiCountOut); +ROLLER_ED_API eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut); + ROLLER_ED_API eRollerEdResult ROLLER_ED_CALL RollerEd_QueryGeometrySizes( tEdGeometrySizes *pSizesOut); /* Caller-owned buffers are written only when this call returns OK. */ diff --git a/PROJECTS/ROLLER/editor_legacy_scene.c b/PROJECTS/ROLLER/editor_legacy_scene.c index e67e69b4..ce281c5a 100644 --- a/PROJECTS/ROLLER/editor_legacy_scene.c +++ b/PROJECTS/ROLLER/editor_legacy_scene.c @@ -19,6 +19,7 @@ #include "roller.h" #include "roller_core_error.h" #include "scene_render_gpu.h" +#include "tower.h" #include "types.h" #define SDL_MAIN_HANDLED 1 @@ -363,6 +364,29 @@ eRollerEdResult roller_ed_legacy_scene_set_graphics_settings( return ROLLER_ED_RESULT_OK; } +uint32_t roller_ed_legacy_scene_tower_count(void) +{ + if (NumTowers <= 0) + return 0u; + if (NumTowers > MAX_TOWERS) + return MAX_TOWERS; + return (uint32_t)NumTowers; +} + +void roller_ed_legacy_scene_query_tower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut) +{ + int iChunkIdx = TowerBase[uiTowerIndex].iChunkIdx; + + pInfoOut->uiChunkId = (uint32_t)iChunkIdx; + pInfoOut->fWorldPosition[0] = TowerX[uiTowerIndex]; + pInfoOut->fWorldPosition[1] = TowerY[uiTowerIndex]; + pInfoOut->fWorldPosition[2] = TowerZ[uiTowerIndex]; + pInfoOut->fAnchorPosition[0] = -localdata[iChunkIdx].pointAy[3].fX; + pInfoOut->fAnchorPosition[1] = -localdata[iChunkIdx].pointAy[3].fY; + pInfoOut->fAnchorPosition[2] = -localdata[iChunkIdx].pointAy[3].fZ; +} + eRollerEdResult roller_ed_legacy_scene_install( const char *szTrackPath, const tEdTrackStage *pStage, diff --git a/PROJECTS/ROLLER/editor_legacy_scene.h b/PROJECTS/ROLLER/editor_legacy_scene.h index 99538928..780cf44b 100644 --- a/PROJECTS/ROLLER/editor_legacy_scene.h +++ b/PROJECTS/ROLLER/editor_legacy_scene.h @@ -61,6 +61,11 @@ eRollerEdResult roller_ed_legacy_scene_set_graphics_settings( char *szError, size_t uiErrorCapacity); +/* Copies authoritative InitTowers results and their loaded chunk anchors. */ +uint32_t roller_ed_legacy_scene_tower_count(void); +void roller_ed_legacy_scene_query_tower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut); + /* * One extraction of the loaded scene's authored geometry, owned by whoever * called extract and released through the matching call. The facade caches diff --git a/tests/editor_api_cpp_test.cpp b/tests/editor_api_cpp_test.cpp index e98e0828..9853436a 100644 --- a/tests/editor_api_cpp_test.cpp +++ b/tests/editor_api_cpp_test.cpp @@ -12,6 +12,8 @@ static_assert(std::is_standard_layout::value, "geometry sizes must be standard layout"); static_assert(std::is_standard_layout::value, "overlay state must be standard layout"); +static_assert(std::is_standard_layout::value, + "tower info must be standard layout"); int main() { @@ -33,5 +35,6 @@ int main() && ROLLER_ED_OVERLAY_STATE_VERSION == 3u && ROLLER_ED_CAMERA_STATE_VERSION == 1u && ROLLER_ED_REFERENCE_MESH_VERSION == 1u + && ROLLER_ED_TOWER_INFO_VERSION == 1u ? 0 : 1; } diff --git a/tests/editor_api_lifecycle_test.c b/tests/editor_api_lifecycle_test.c index f494f681..38bfe2b1 100644 --- a/tests/editor_api_lifecycle_test.c +++ b/tests/editor_api_lifecycle_test.c @@ -39,6 +39,23 @@ static tEdGraphicsSettings s_LastLegacyGraphics; static uint32_t s_uiStubQuadCount; static int s_iStubExtractCount; +uint32_t roller_ed_legacy_scene_tower_count(void) +{ + return 2u; +} + +void roller_ed_legacy_scene_query_tower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut) +{ + pInfoOut->uiChunkId = 40u + uiTowerIndex; + pInfoOut->fWorldPosition[0] = (float)uiTowerIndex + 0.25f; + pInfoOut->fWorldPosition[1] = (float)uiTowerIndex + 1.25f; + pInfoOut->fWorldPosition[2] = (float)uiTowerIndex + 2.25f; + pInfoOut->fAnchorPosition[0] = (float)uiTowerIndex - 10.0f; + pInfoOut->fAnchorPosition[1] = (float)uiTowerIndex - 20.0f; + pInfoOut->fAnchorPosition[2] = (float)uiTowerIndex - 30.0f; +} + eRollerEdResult roller_ed_legacy_scene_install( const char *szTrackPath, const tEdTrackStage *pStage, @@ -351,6 +368,38 @@ static int SDLCALL lifecycle_worker(void *pUserData) CHECK_WORKER(Sizes.uiMaterialStride == sizeof(tEdMaterial)); uiInitialEpoch = Sizes.uiGeometryEpoch; uiInitialGeneration = Sizes.uiTrackGeneration; + { + uint32_t uiTowerCount = 0x87654321u; + tEdTowerInfo TowerInfo; + tEdTowerInfo Before; + + memset(&TowerInfo, 0xa5, sizeof(TowerInfo)); + TowerInfo.uiStructSize = sizeof(TowerInfo); + TowerInfo.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + Before = TowerInfo; + CHECK_WORKER(RollerEd_QueryTowerCount(&uiTowerCount) + == ROLLER_ED_RESULT_NO_SCENE); + CHECK_WORKER(uiTowerCount == 0x87654321u); + CHECK_WORKER(RollerEd_QueryTower(0u, &TowerInfo) + == ROLLER_ED_RESULT_NO_SCENE); + CHECK_WORKER(memcmp(&TowerInfo, &Before, sizeof(Before)) == 0); + CHECK_WORKER(RollerEd_QueryTowerCount(NULL) + == ROLLER_ED_RESULT_INVALID_ARGUMENT); + CHECK_WORKER(RollerEd_QueryTower(0u, NULL) + == ROLLER_ED_RESULT_INVALID_ARGUMENT); + + TowerInfo.uiVersion = ROLLER_ED_TOWER_INFO_VERSION + 1u; + Before = TowerInfo; + CHECK_WORKER(RollerEd_QueryTower(0u, &TowerInfo) + == ROLLER_ED_RESULT_INVALID_VERSION); + CHECK_WORKER(memcmp(&TowerInfo, &Before, sizeof(Before)) == 0); + TowerInfo.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + TowerInfo.uiStructSize = sizeof(TowerInfo) - 1u; + Before = TowerInfo; + CHECK_WORKER(RollerEd_QueryTower(0u, &TowerInfo) + == ROLLER_ED_RESULT_INVALID_ARGUMENT); + CHECK_WORKER(memcmp(&TowerInfo, &Before, sizeof(Before)) == 0); + } SDL_SignalSemaphore(pContext->pReady); SDL_WaitSemaphore(pContext->pContinue); @@ -596,6 +645,38 @@ static int SDLCALL lifecycle_worker(void *pUserData) CHECK_WORKER(Sizes.uiSceneState == ROLLER_ED_SCENE_READY); CHECK_WORKER(Sizes.uiTrackGeneration != uiInitialGeneration); CHECK_WORKER(Sizes.uiGeometryEpoch != uiInitialEpoch); + { + uint32_t uiTowerCount = 0u; + tEdTowerInfo TowerInfo = { + .uiStructSize = sizeof(TowerInfo), + .uiVersion = ROLLER_ED_TOWER_INFO_VERSION + }; + tEdTowerInfo Before; + + CHECK_WORKER(RollerEd_QueryTowerCount(&uiTowerCount) + == ROLLER_ED_RESULT_OK); + CHECK_WORKER(uiTowerCount == 2u); + CHECK_WORKER(RollerEd_QueryTower(1u, &TowerInfo) + == ROLLER_ED_RESULT_OK); + CHECK_WORKER(TowerInfo.uiStructSize == sizeof(TowerInfo)); + CHECK_WORKER(TowerInfo.uiVersion + == ROLLER_ED_TOWER_INFO_VERSION); + CHECK_WORKER(TowerInfo.uiChunkId == 41u); + CHECK_WORKER(TowerInfo.fWorldPosition[0] == 1.25f + && TowerInfo.fWorldPosition[1] == 2.25f + && TowerInfo.fWorldPosition[2] == 3.25f); + CHECK_WORKER(TowerInfo.fAnchorPosition[0] == -9.0f + && TowerInfo.fAnchorPosition[1] == -19.0f + && TowerInfo.fAnchorPosition[2] == -29.0f); + + memset(&TowerInfo, 0x5a, sizeof(TowerInfo)); + TowerInfo.uiStructSize = sizeof(TowerInfo); + TowerInfo.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + Before = TowerInfo; + CHECK_WORKER(RollerEd_QueryTower(uiTowerCount, &TowerInfo) + == ROLLER_ED_RESULT_INVALID_ARGUMENT); + CHECK_WORKER(memcmp(&TowerInfo, &Before, sizeof(Before)) == 0); + } { const uint32_t uiEpochBeforeStunts = Sizes.uiGeometryEpoch; const uint32_t uiGenerationBeforeStunts = @@ -808,6 +889,24 @@ static int SDLCALL lifecycle_worker(void *pUserData) CHECK_WORKER(Sizes.uiGeometryEpoch != uiReadyEpoch); CHECK_WORKER(RollerEd_GetLastError()[0] == '\0'); + { + uint32_t uiTowerCount = 0xfedcba98u; + tEdTowerInfo TowerInfo; + tEdTowerInfo TowerBefore; + + memset(&TowerInfo, 0x6b, sizeof(TowerInfo)); + TowerInfo.uiStructSize = sizeof(TowerInfo); + TowerInfo.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + TowerBefore = TowerInfo; + CHECK_WORKER(RollerEd_QueryTowerCount(&uiTowerCount) + == ROLLER_ED_RESULT_NO_SCENE); + CHECK_WORKER(uiTowerCount == 0xfedcba98u); + CHECK_WORKER(RollerEd_QueryTower(0u, &TowerInfo) + == ROLLER_ED_RESULT_NO_SCENE); + CHECK_WORKER(memcmp(&TowerInfo, &TowerBefore, + sizeof(TowerBefore)) == 0); + } + { tEdVertex Vertex; tEdVertex Before; @@ -1020,6 +1119,12 @@ int main(int argc, char **argv) .uiStructSize = sizeof(Sizes), .uiVersion = ROLLER_ED_GEOMETRY_SIZES_VERSION }; + uint32_t uiTowerCount = 0x12345678u; + tEdTowerInfo TowerInfo = { + .uiStructSize = sizeof(TowerInfo), + .uiVersion = ROLLER_ED_TOWER_INFO_VERSION + }; + tEdTowerInfo TowerBefore = TowerInfo; tRollerEdInitInfo InitInfo = { .uiStructSize = sizeof(InitInfo), .uiVersion = ROLLER_ED_INIT_INFO_VERSION, @@ -1031,6 +1136,19 @@ int main(int argc, char **argv) RollerEd_QueryGeometrySizes(&Sizes) == ROLLER_ED_RESULT_WRONG_THREAD, __LINE__); + if (iMainFailure == 0) + iMainFailure = check_condition( + RollerEd_QueryTowerCount(&uiTowerCount) + == ROLLER_ED_RESULT_WRONG_THREAD + && uiTowerCount == 0x12345678u, + __LINE__); + if (iMainFailure == 0) + iMainFailure = check_condition( + RollerEd_QueryTower(0u, &TowerInfo) + == ROLLER_ED_RESULT_WRONG_THREAD + && memcmp(&TowerInfo, &TowerBefore, + sizeof(TowerBefore)) == 0, + __LINE__); if (iMainFailure == 0) iMainFailure = check_condition( RollerEd_Init(&InitInfo) == ROLLER_ED_RESULT_WRONG_THREAD, diff --git a/tests/editor_reload_soak_acceptance.c b/tests/editor_reload_soak_acceptance.c index fee0f5ae..f14a6d96 100644 --- a/tests/editor_reload_soak_acceptance.c +++ b/tests/editor_reload_soak_acceptance.c @@ -510,6 +510,8 @@ static int soak_build_tower_limit_input(tSoakContext *pContext) static int soak_verify_tower_limit(tSoakContext *pContext) { + uint32_t uiTowerCount = 0u; + if (RollerEd_LoadTrackFile(pContext->szTowerLimitTrack, pContext->szAssetRoot) != ROLLER_ED_RESULT_OK) { @@ -522,7 +524,20 @@ static int soak_verify_tower_limit(tSoakContext *pContext) NumTowers, MAX_TOWERS); return 0; } + if (RollerEd_QueryTowerCount(&uiTowerCount) != ROLLER_ED_RESULT_OK + || uiTowerCount != MAX_TOWERS) { + soak_fail(pContext, + "tower query returned %u entries; expected %d: %s", + uiTowerCount, MAX_TOWERS, RollerEd_GetLastError()); + return 0; + } for (int iTower = 0; iTower < MAX_TOWERS; ++iTower) { + tEdTowerInfo Info = { + .uiStructSize = sizeof(Info), + .uiVersion = ROLLER_ED_TOWER_INFO_VERSION + }; + int iChunkIdx = TowerBase[iTower].iChunkIdx; + if (TowerBase[iTower].iChunkIdx != iTower || TowerBase[iTower].iHOffset != iTower || TowerBase[iTower].iVOffset != -iTower @@ -534,6 +549,25 @@ static int soak_verify_tower_limit(tSoakContext *pContext) iTower); return 0; } + if (RollerEd_QueryTower((uint32_t)iTower, &Info) + != ROLLER_ED_RESULT_OK + || Info.uiStructSize != sizeof(Info) + || Info.uiVersion != ROLLER_ED_TOWER_INFO_VERSION + || Info.uiChunkId != (uint32_t)iChunkIdx + || Info.fWorldPosition[0] != TowerX[iTower] + || Info.fWorldPosition[1] != TowerY[iTower] + || Info.fWorldPosition[2] != TowerZ[iTower] + || Info.fAnchorPosition[0] + != -localdata[iChunkIdx].pointAy[3].fX + || Info.fAnchorPosition[1] + != -localdata[iChunkIdx].pointAy[3].fY + || Info.fAnchorPosition[2] + != -localdata[iChunkIdx].pointAy[3].fZ) { + soak_fail(pContext, + "tower query %d disagreed with InitTowers output", + iTower); + return 0; + } } for (int iChunk = MAX_TOWERS; iChunk < SOAK_TOWER_INPUT_COUNT; ++iChunk) { @@ -544,7 +578,23 @@ static int soak_verify_tower_limit(tSoakContext *pContext) return 0; } } - printf(" E7-S1: retained the first %d of %d authored towers\n", + { + tEdTowerInfo Info; + tEdTowerInfo Before; + + memset(&Info, 0xc7, sizeof(Info)); + Info.uiStructSize = sizeof(Info); + Info.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + Before = Info; + if (RollerEd_QueryTower(uiTowerCount, &Info) + != ROLLER_ED_RESULT_INVALID_ARGUMENT + || memcmp(&Info, &Before, sizeof(Before)) != 0) { + soak_fail(pContext, + "out-of-range tower query changed caller output"); + return 0; + } + } + printf(" E7-S1/S2: queried the first %d of %d authored towers\n", NumTowers, SOAK_TOWER_INPUT_COUNT); return 1; } diff --git a/tests/test_e7_s2_tower_query.py b/tests/test_e7_s2_tower_query.py new file mode 100644 index 00000000..be1caacd --- /dev/null +++ b/tests/test_e7_s2_tower_query.py @@ -0,0 +1,140 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ROLLER = ROOT / "PROJECTS" / "ROLLER" + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[brace + 1 : index] + raise AssertionError(f"unterminated function: {signature}") + + +def without_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) + return re.sub(r"//.*", "", source) + + +class TowerQueryAbiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.header = (ROLLER / "editor_api.h").read_text(encoding="utf-8") + cls.cpp = (ROOT / "tests" / "editor_api_cpp_test.cpp").read_text( + encoding="utf-8" + ) + + def test_tower_info_is_a_sized_versioned_value_type(self) -> None: + for field in ( + "uiStructSize", + "uiVersion", + "uiChunkId", + "fWorldPosition[3]", + "fAnchorPosition[3]", + ): + self.assertIn(field, self.header) + self.assertIn("#define ROLLER_ED_TOWER_INFO_VERSION 1u", self.header) + + def test_c_and_cpp_pin_the_same_layout(self) -> None: + for assertion in ( + "sizeof(tEdTowerInfo) == 36u", + "ROLLER_ED_ALIGNOF(tEdTowerInfo) == 4u", + "offsetof(tEdTowerInfo, uiChunkId) == 8u", + "offsetof(tEdTowerInfo, fWorldPosition) == 12u", + "offsetof(tEdTowerInfo, fAnchorPosition) == 24u", + ): + self.assertIn(assertion, self.header) + self.assertIn("std::is_standard_layout", self.cpp) + self.assertIn("ROLLER_ED_TOWER_INFO_VERSION == 1u", self.cpp) + + def test_public_queries_return_named_results(self) -> None: + self.assertRegex( + self.header, + r"eRollerEdResult\s+ROLLER_ED_CALL\s+RollerEd_QueryTowerCount\s*\(", + ) + self.assertRegex( + self.header, + r"eRollerEdResult\s+ROLLER_ED_CALL\s+RollerEd_QueryTower\s*\(", + ) + + +class TowerQueryBoundaryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api = (ROLLER / "editor_api.c").read_text(encoding="utf-8") + cls.scene = (ROLLER / "editor_legacy_scene.c").read_text( + encoding="utf-8" + ) + cls.lifecycle = (ROOT / "tests" / "editor_api_lifecycle_test.c").read_text( + encoding="utf-8" + ) + cls.soak = (ROOT / "tests" / "editor_reload_soak_acceptance.c").read_text( + encoding="utf-8" + ) + + def test_both_queries_require_the_worker_and_a_ready_scene(self) -> None: + for signature in ( + "eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTowerCount(", + "eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTower(", + ): + body = without_comments(function_body(self.api, signature)) + self.assertIn("roller_ed_require_worker()", body) + self.assertIn("s_eSceneState != ROLLER_ED_SCENE_READY", body) + self.assertIn("ROLLER_ED_RESULT_NO_SCENE", body) + + def test_info_is_validated_and_copied_only_after_success(self) -> None: + body = without_comments( + function_body( + self.api, + "eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTower(", + ) + ) + self.assertIn("roller_ed_validate_struct(", body) + self.assertIn("ROLLER_ED_TOWER_INFO_VERSION", body) + self.assertIn("uiTowerIndex >= uiCount", body) + self.assertIn("tEdTowerInfo Info", body) + self.assertLess(body.index("roller_ed_legacy_scene_query_tower("), + body.index("*pInfoOut = Info")) + + def test_legacy_seam_publishes_loaded_positions_and_anchor(self) -> None: + body = without_comments( + function_body( + self.scene, "void roller_ed_legacy_scene_query_tower(" + ) + ) + for table in ("TowerBase[", "TowerX[", "TowerY[", "TowerZ["): + self.assertIn(table, body) + self.assertIn("localdata[iChunkIdx].pointAy[3].fX", body) + self.assertIn("localdata[iChunkIdx].pointAy[3].fY", body) + self.assertIn("localdata[iChunkIdx].pointAy[3].fZ", body) + self.assertNotIn("sqrt", body) + + def test_lifecycle_covers_wrong_thread_scene_and_version_errors(self) -> None: + for result in ( + "ROLLER_ED_RESULT_WRONG_THREAD", + "ROLLER_ED_RESULT_NO_SCENE", + "ROLLER_ED_RESULT_INVALID_VERSION", + "ROLLER_ED_RESULT_INVALID_ARGUMENT", + ): + self.assertIn(result, self.lifecycle) + self.assertIn("memcmp(&TowerInfo, &Before", self.lifecycle) + + def test_real_facade_fixture_round_trips_all_bounded_towers(self) -> None: + self.assertIn("RollerEd_QueryTowerCount(&uiTowerCount)", self.soak) + self.assertIn("RollerEd_QueryTower((uint32_t)iTower, &Info)", self.soak) + self.assertIn("Info.fWorldPosition[0] != TowerX[iTower]", self.soak) + self.assertIn("-localdata[iChunkIdx].pointAy[3].fX", self.soak) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_editor_api_contract.py b/tests/test_editor_api_contract.py index 40e88dbd..429c9c24 100644 --- a/tests/test_editor_api_contract.py +++ b/tests/test_editor_api_contract.py @@ -68,6 +68,8 @@ def test_all_fallible_facade_calls_return_named_results(self) -> None: "RollerEd_RenderFrame", "RollerEd_SetOverlayState", "RollerEd_SetReferenceMesh", + "RollerEd_QueryTowerCount", + "RollerEd_QueryTower", "RollerEd_QueryGeometrySizes", "RollerEd_FillGeometry", ): From aefd24915960ccee2eb9bcce008f96e2f967ffb4 Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:40:11 -0700 Subject: [PATCH 3/3] tower markers reuse DrawTower billboard --- PROJECTS/ROLLER/drawtrk3.c | 4 + PROJECTS/ROLLER/editor_api.h | 13 +- PROJECTS/ROLLER/editor_overlay.c | 4 + PROJECTS/ROLLER/editor_overlay.h | 13 +- PROJECTS/ROLLER/tower.c | 104 ++++++++---- PROJECTS/ROLLER/tower.h | 1 + build.zig | 26 +++ tests/editor_api_cpp_test.cpp | 1 + tests/editor_api_lifecycle_test.c | 5 +- tests/editor_overlay_test.c | 36 ++++- tests/editor_overlay_toggle_acceptance.c | 152 ++++++++++++++++++ tests/editor_reload_soak_acceptance.c | 35 ++++- tests/test_e3a_s1_overlay_state.py | 1 + tests/test_e3a_s4_helper_overlays.py | 5 +- tests/test_e7_s3_tower_markers.py | 191 +++++++++++++++++++++++ tests/test_editor_api_contract.py | 1 + tests/tower_marker_test.c | 173 ++++++++++++++++++++ 17 files changed, 715 insertions(+), 50 deletions(-) create mode 100644 tests/test_e7_s3_tower_markers.py create mode 100644 tests/tower_marker_test.c diff --git a/PROJECTS/ROLLER/drawtrk3.c b/PROJECTS/ROLLER/drawtrk3.c index 9d235513..0871a123 100644 --- a/PROJECTS/ROLLER/drawtrk3.c +++ b/PROJECTS/ROLLER/drawtrk3.c @@ -446,6 +446,10 @@ void drawtrk3_editor_draw_helpers(GameRenderer *pRenderer) ED_STUNT_MARKER_PALETTE_COLOUR); } } + if (roller_ed_overlay_enabled(ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS)) { + for (int iTowerIdx = 0; iTowerIdx < NumTowers; iTowerIdx++) + tower_emit_marker(iTowerIdx, 1.0f); + } } static void draw_emitted_surface(const tEdSurfaceEmission *pSurface, diff --git a/PROJECTS/ROLLER/editor_api.h b/PROJECTS/ROLLER/editor_api.h index 84d71e60..345068a6 100644 --- a/PROJECTS/ROLLER/editor_api.h +++ b/PROJECTS/ROLLER/editor_api.h @@ -234,7 +234,14 @@ enum * rather than a design of its own. F1WACK and DEATH have no Y variant and * ignore it, exactly as the game does. */ - ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED = 1u << 11 + ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED = 1u << 11, + /* + * E7-S3. Camera-tower markers reuse the game's constant-screen-size + * DrawTower billboard. They are editor furniture rather than authored + * export geometry, and their visibility is independent of the surface + * master and per-class masks. + */ + ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS = 1u << 12 }; /* @@ -251,7 +258,9 @@ enum * Overlay class masks. SHOW_SURFACES and SHOW_WIREFRAME are master switches; * each mask then selects which surface classes that switch applies to, one bit * per eRollerEdSurfaceClass value. A class is drawn only when both agree, so - * the host can blank the view without losing its per-class choices. + * the host can blank the view without losing its per-class choices. The tower + * marker is editor furniture and is the sole exception: its solid visibility + * follows ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS regardless of these masks. */ #define ROLLER_ED_OVERLAY_CLASS_BIT(surface_class) \ (1u << (uint32_t)(surface_class)) diff --git a/PROJECTS/ROLLER/editor_overlay.c b/PROJECTS/ROLLER/editor_overlay.c index 65075eed..b1caf1e7 100644 --- a/PROJECTS/ROLLER/editor_overlay.c +++ b/PROJECTS/ROLLER/editor_overlay.c @@ -66,6 +66,10 @@ void roller_ed_overlay_get(tEdOverlayState *pStateOut) bool roller_ed_overlay_surface_class_visible(uint16_t unSurfaceClass) { + if (unSurfaceClass == ROLLER_ED_SURFACE_CLASS_TOWER) { + return roller_ed_overlay_enabled( + ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS); + } return roller_ed_overlay_enabled(ROLLER_ED_OVERLAY_SHOW_SURFACES) && overlay_class_selected(s_uiSurfaceClassMask, unSurfaceClass); } diff --git a/PROJECTS/ROLLER/editor_overlay.h b/PROJECTS/ROLLER/editor_overlay.h index 697022f8..557db87f 100644 --- a/PROJECTS/ROLLER/editor_overlay.h +++ b/PROJECTS/ROLLER/editor_overlay.h @@ -34,7 +34,8 @@ | ROLLER_ED_OVERLAY_SHOW_TEST_CAR \ | ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH \ | ROLLER_ED_OVERLAY_TEST_CAR_MILLION_PLUS \ - | ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED) + | ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED \ + | ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) /* * The defaults reproduce the E1-S6 track-only view exactly: every surface @@ -74,10 +75,12 @@ bool roller_ed_overlay_selection_range(uint32_t *puiFirstChunk, /* * E3A-S2. Both queries are the master flag AND the class's mask bit, so a host - * can blank the whole view without losing its per-class choices. A class value - * at or beyond ROLLER_ED_SURFACE_CLASS_COUNT is never drawn either way: the - * emitter refuses those identities, so seeing one here means something already - * went wrong upstream. + * can blank the whole view without losing its per-class choices. E7-S3's tower + * marker is the one surface-class exception: its solid visibility follows only + * SHOW_TOWER_MARKERS, allowing editor furniture to remain in a surface-free + * wireframe view. A class value at or beyond ROLLER_ED_SURFACE_CLASS_COUNT is + * never drawn either way: the emitter refuses those identities, so seeing one + * here means something already went wrong upstream. */ bool roller_ed_overlay_surface_class_visible(uint16_t unSurfaceClass); bool roller_ed_overlay_wireframe_class_visible(uint16_t unSurfaceClass); diff --git a/PROJECTS/ROLLER/tower.c b/PROJECTS/ROLLER/tower.c index ac563f82..e1106237 100644 --- a/PROJECTS/ROLLER/tower.c +++ b/PROJECTS/ROLLER/tower.c @@ -78,6 +78,80 @@ void InitTowers() TowerPol.uiNumVerts = 4; // Set tower polygon to 4 vertices (rectangular) } +//------------------------------------------------------------------------------------------------- +/* + * E7-S3. Shared camera-facing, constant-screen-size tower marker. DrawTower + * retains the legacy gates and visibility heuristic around this seam, while + * the editor calls it directly for every loaded tower so authored camera + * modes, NearTow, and the game-only screen heuristic cannot suppress one. + */ +void tower_emit_marker(int iTowerIdx, float fScale) +{ + double TowerMinusViewX; + double TowerMinusViewY; + double TowerMinusViewZ; + double dTransformed3DZ; + float fClampedZ; + GameRenderVertex verts[4]; + float fHalfPixelSize; + float viewOffsetX[4]; + float viewOffsetY[4]; + + if (iTowerIdx < 0 || iTowerIdx >= NumTowers || iTowerIdx >= MAX_TOWERS + || !g_pGameRenderer || !(fScale > 0.0f) || !isfinite(fScale)) + return; + + TowerMinusViewX = TowerX[iTowerIdx] - viewx; + TowerMinusViewY = TowerY[iTowerIdx] - viewy; + TowerMinusViewZ = TowerZ[iTowerIdx] - viewz; + dTransformed3DZ = (float)TowerMinusViewX * vk3 + + (float)TowerMinusViewY * vk6 + + (float)TowerMinusViewZ * vk9; + fClampedZ = (float)dTransformed3DZ; + if (dTransformed3DZ < 80.0) + fClampedZ = 80.0f; + + fHalfPixelSize = fClampedZ * 3.0f * 64.0f + / ((float)scr_size * (float)VIEWDIST); + fHalfPixelSize *= fScale; + viewOffsetX[0] = fHalfPixelSize; + viewOffsetX[1] = -fHalfPixelSize; + viewOffsetX[2] = -fHalfPixelSize; + viewOffsetX[3] = fHalfPixelSize; + viewOffsetY[0] = fHalfPixelSize; + viewOffsetY[1] = fHalfPixelSize; + viewOffsetY[2] = -fHalfPixelSize; + viewOffsetY[3] = -fHalfPixelSize; + + for (int vi = 0; vi < 4; vi++) { + verts[vi].x = TowerX[iTowerIdx] + + viewOffsetX[vi] * vk1 + viewOffsetY[vi] * vk2; + verts[vi].y = TowerY[iTowerIdx] + + viewOffsetX[vi] * vk4 + viewOffsetY[vi] * vk5; + verts[vi].z = TowerZ[iTowerIdx] + + viewOffsetX[vi] * vk7 + viewOffsetY[vi] * vk8; + verts[vi].u = 0.0f; + verts[vi].v = 0.0f; + } + { + tEdSurfaceInfo SurfaceInfo; + memset(&SurfaceInfo, 0, sizeof(SurfaceInfo)); + SurfaceInfo.uiChunkId = TowerBase[iTowerIdx].iChunkIdx >= 0 + ? (uint32_t)TowerBase[iTowerIdx].iChunkIdx + : ROLLER_ED_INVALID_CHUNK_ID; + SurfaceInfo.uiRenderFlags = SURFACE_FLAG_FLIP_BACKFACE | 0xE7; + SurfaceInfo.uiBackSurfaceFlags = ED_MATERIAL_ID_NONE; + SurfaceInfo.uiTextureSet = TEXTURE_BANK_TRACK; + SurfaceInfo.fSubdivideThreshold = 1.0f; + SurfaceInfo.unSurfaceClass = ROLLER_ED_SURFACE_CLASS_TOWER; + SurfaceInfo.unContentClass = ROLLER_ED_CONTENT_RUNTIME_SCENERY; + SurfaceInfo.byTopology = ROLLER_ED_TOPOLOGY_QUAD; + SurfaceInfo.byRenderUVLayout = ROLLER_ED_RENDER_UV_TILE; + SurfaceInfo.iRenderSubdivideType = GAME_RENDER_SUBDIVIDE_TYPE_AUTO; + drawtrk3_emit_surface_to_renderer(g_pGameRenderer, verts, &SurfaceInfo); + } +} + //------------------------------------------------------------------------------------------------- //00075850 void DrawTower(int iTowerIdx, uint8 *pScrBuf) @@ -124,35 +198,7 @@ void DrawTower(int iTowerIdx, uint8 *pScrBuf) iPixelY = (iScreenSize * (199 - (int)dScreenY)) >> 6;// Complex visibility test for different distance ranges if (fOriginalZ >= 5000.0 && xp >= -50 && xp < 370 && yp >= -50 && yp < 250 || fOriginalZ > -1000.0 && fOriginalZ < 5000.0 && (fTransformed3DX > -1000.0 && fTransformed3DX < 1000.0 || xp > -200 && xp < 520)) { - GameRenderVertex verts[4]; - float fHalfPixelSize = fClampedZ * 3.0f * 64.0f / ((float)scr_size * (float)VIEWDIST); - float viewOffsetX[4] = { fHalfPixelSize, -fHalfPixelSize, -fHalfPixelSize, fHalfPixelSize }; - float viewOffsetY[4] = { fHalfPixelSize, fHalfPixelSize, -fHalfPixelSize, -fHalfPixelSize }; - for (int vi = 0; vi < 4; vi++) { - verts[vi].x = TowerX[iTowerIdx] + viewOffsetX[vi] * vk1 + viewOffsetY[vi] * vk2; - verts[vi].y = TowerY[iTowerIdx] + viewOffsetX[vi] * vk4 + viewOffsetY[vi] * vk5; - verts[vi].z = TowerZ[iTowerIdx] + viewOffsetX[vi] * vk7 + viewOffsetY[vi] * vk8; - verts[vi].u = 0.0f; - verts[vi].v = 0.0f; - } - { - tEdSurfaceInfo SurfaceInfo; - memset(&SurfaceInfo, 0, sizeof(SurfaceInfo)); - SurfaceInfo.uiChunkId = TowerBase[iTowerIdx].iChunkIdx >= 0 - ? (uint32_t)TowerBase[iTowerIdx].iChunkIdx - : ROLLER_ED_INVALID_CHUNK_ID; - SurfaceInfo.uiRenderFlags = SURFACE_FLAG_FLIP_BACKFACE | 0xE7; - SurfaceInfo.uiBackSurfaceFlags = ED_MATERIAL_ID_NONE; - SurfaceInfo.uiTextureSet = TEXTURE_BANK_TRACK; - SurfaceInfo.fSubdivideThreshold = 1.0f; - SurfaceInfo.unSurfaceClass = ROLLER_ED_SURFACE_CLASS_TOWER; - SurfaceInfo.unContentClass = ROLLER_ED_CONTENT_RUNTIME_SCENERY; - SurfaceInfo.byTopology = ROLLER_ED_TOPOLOGY_QUAD; - SurfaceInfo.byRenderUVLayout = ROLLER_ED_RENDER_UV_TILE; - SurfaceInfo.iRenderSubdivideType = GAME_RENDER_SUBDIVIDE_TYPE_AUTO; - drawtrk3_emit_surface_to_renderer( - g_pGameRenderer, verts, &SurfaceInfo); - } + tower_emit_marker(iTowerIdx, 1.0f); } } } diff --git a/PROJECTS/ROLLER/tower.h b/PROJECTS/ROLLER/tower.h index 1256b2c5..fa79e7b5 100644 --- a/PROJECTS/ROLLER/tower.h +++ b/PROJECTS/ROLLER/tower.h @@ -28,6 +28,7 @@ extern int NumTowers; //------------------------------------------------------------------------------------------------- void InitTowers(); +void tower_emit_marker(int iTowerIdx, float fScale); void DrawTower(int iTowerIdx, uint8 *pScrBuf); //------------------------------------------------------------------------------------------------- diff --git a/build.zig b/build.zig index 6a96f6a0..22771f9d 100644 --- a/build.zig +++ b/build.zig @@ -623,6 +623,11 @@ fn configureRenderQueue3DTests( "Run audio/stunt marker overlay acceptance (retail assets)", ); editor_marker_overlay_tests.dependOn(&run_editor_overlay_toggle.step); + const editor_tower_marker_tests = b.step( + "test-e7-s3-tower-markers", + "Run tower billboard marker overlay acceptance (retail assets)", + ); + editor_tower_marker_tests.dependOn(&run_editor_overlay_toggle.step); const editor_test_car_tests = b.step( "test-e3a-s6-test-car", "Run configurable test car acceptance (retail assets)", @@ -1022,6 +1027,27 @@ fn configureRenderQueue3DTests( const run_editor_overlay = b.addRunArtifact(editor_overlay_exe); editor_api_tests.dependOn(&run_editor_overlay.step); + const tower_marker_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }); + tower_marker_mod.addIncludePath(sdl.builder.path("include")); + tower_marker_mod.addIncludePath(b.path("PROJECTS/ROLLER")); + tower_marker_mod.addCSourceFiles(.{ + .flags = c_flags, + .files = &.{ + "PROJECTS/ROLLER/tower.c", + "tests/tower_marker_test.c", + }, + }); + const tower_marker_exe = b.addExecutable(.{ + .name = "tower_marker_test", + .root_module = tower_marker_mod, + }); + const run_tower_marker = b.addRunArtifact(tower_marker_exe); + editor_api_tests.dependOn(&run_tower_marker.step); + const editor_helpers_mod = b.createModule(.{ .target = target, .optimize = optimize, diff --git a/tests/editor_api_cpp_test.cpp b/tests/editor_api_cpp_test.cpp index 9853436a..46021309 100644 --- a/tests/editor_api_cpp_test.cpp +++ b/tests/editor_api_cpp_test.cpp @@ -30,6 +30,7 @@ int main() && ROLLER_ED_OVERLAY_ALL_SURFACE_CLASSES == 0x3fffu && ROLLER_ED_OVERLAY_TEST_CAR_MILLION_PLUS == (1u << 10) && ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED == (1u << 11) + && ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS == (1u << 12) && ROLLER_ED_TEST_CAR_DESIGN_COUNT == 14u && ROLLER_ED_TEST_CAR_AI_LINE_COUNT == 4u && ROLLER_ED_OVERLAY_STATE_VERSION == 3u diff --git a/tests/editor_api_lifecycle_test.c b/tests/editor_api_lifecycle_test.c index 38bfe2b1..81b5e293 100644 --- a/tests/editor_api_lifecycle_test.c +++ b/tests/editor_api_lifecycle_test.c @@ -452,7 +452,8 @@ static int SDLCALL lifecycle_worker(void *pUserData) .uiFlags = ROLLER_ED_OVERLAY_SHOW_SURFACES | ROLLER_ED_OVERLAY_SHOW_WIREFRAME | ROLLER_ED_OVERLAY_HIGHLIGHT_SELECTION - | ROLLER_ED_OVERLAY_SHOW_STUNT_MARKERS, + | ROLLER_ED_OVERLAY_SHOW_STUNT_MARKERS + | ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS, .uiFirstSelectedChunk = 31u, .uiLastSelectedChunk = 12u, .uiSurfaceClassMask = ROLLER_ED_OVERLAY_ALL_SURFACE_CLASSES, @@ -509,7 +510,7 @@ static int SDLCALL lifecycle_worker(void *pUserData) /* A bit this API version does not define is refused whole rather than * quietly dropped, so the host never believes it enabled something. */ InvalidOverlay = Overlay; - InvalidOverlay.uiFlags |= 1u << 12; + InvalidOverlay.uiFlags |= 1u << 13; CHECK_WORKER(RollerEd_SetOverlayState(&InvalidOverlay) == ROLLER_ED_RESULT_INVALID_ARGUMENT); CHECK_WORKER(strstr(RollerEd_GetLastError(), "uiFlags") != NULL); diff --git a/tests/editor_overlay_test.c b/tests/editor_overlay_test.c index eaae7b23..d66a2f21 100644 --- a/tests/editor_overlay_test.c +++ b/tests/editor_overlay_test.c @@ -61,7 +61,8 @@ int main(void) ROLLER_ED_OVERLAY_SHOW_TEST_CAR, ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH, ROLLER_ED_OVERLAY_TEST_CAR_MILLION_PLUS, - ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED + ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED, + ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS }; const uint32_t uiKnownFlags = (uint32_t)ROLLER_ED_OVERLAY_KNOWN_FLAGS; tEdOverlayState State; @@ -76,10 +77,10 @@ int main(void) for (size_t i = 0; i < sizeof(auiFlags) / sizeof(auiFlags[0]); ++i) uiUnion |= auiFlags[i]; CHECK(uiUnion == uiKnownFlags); - /* Bit 12 is the first undefined one; bit 5 is reserved, having been + /* Bit 13 is the first undefined one; bit 5 is reserved, having been * the retired environment floor, and must stay refused rather than * being quietly reused. */ - CHECK((uiKnownFlags & (1u << 12)) == 0u); + CHECK((uiKnownFlags & (1u << 13)) == 0u); CHECK((uiKnownFlags & (1u << 5)) == 0u); } @@ -88,7 +89,8 @@ int main(void) roller_ed_overlay_reset(); for (uint32_t uiClass = 0u; uiClass < ROLLER_ED_SURFACE_CLASS_COUNT; uiClass++) { - CHECK(roller_ed_overlay_surface_class_visible((uint16_t)uiClass)); + CHECK(roller_ed_overlay_surface_class_visible((uint16_t)uiClass) + == (uiClass != ROLLER_ED_SURFACE_CLASS_TOWER)); CHECK(!roller_ed_overlay_wireframe_class_visible((uint16_t)uiClass)); } CHECK(!roller_ed_overlay_surface_class_visible( @@ -186,12 +188,36 @@ int main(void) * does not define is never stored. */ { tEdOverlayState Unknown = make_state( - ROLLER_ED_OVERLAY_SHOW_SURFACES | (1u << 12) | (1u << 31), 1u, 2u); + ROLLER_ED_OVERLAY_SHOW_SURFACES | (1u << 13) | (1u << 31), 1u, 2u); roller_ed_overlay_set(&Unknown); CHECK(roller_ed_overlay_flags() == ROLLER_ED_OVERLAY_SHOW_SURFACES); } + /* E7-S3: tower markers are editor furniture. Their surface emission is + * visible solely under the marker flag, even in a surface-free view with + * every class-mask bit clear. Other classes keep the E3A-S2 master/mask + * contract unchanged. */ + { + tEdOverlayState TowersOnly = make_masked_state( + ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS, 0u, 0u, 0u, 0u); + + roller_ed_overlay_set(&TowersOnly); + CHECK(roller_ed_overlay_surface_class_visible( + ROLLER_ED_SURFACE_CLASS_TOWER)); + CHECK(!roller_ed_overlay_surface_class_visible( + ROLLER_ED_SURFACE_CLASS_CENTER)); + CHECK(!roller_ed_overlay_wireframe_class_visible( + ROLLER_ED_SURFACE_CLASS_TOWER)); + + TowersOnly.uiFlags = ROLLER_ED_OVERLAY_SHOW_SURFACES; + TowersOnly.uiSurfaceClassMask = + ROLLER_ED_OVERLAY_CLASS_BIT(ROLLER_ED_SURFACE_CLASS_TOWER); + roller_ed_overlay_set(&TowersOnly); + CHECK(!roller_ed_overlay_surface_class_visible( + ROLLER_ED_SURFACE_CLASS_TOWER)); + } + /* A partial match is not a match, and no flag at all is never enabled. */ { tEdOverlayState Pair = make_state( diff --git a/tests/editor_overlay_toggle_acceptance.c b/tests/editor_overlay_toggle_acceptance.c index e40aa2b2..e697bf76 100644 --- a/tests/editor_overlay_toggle_acceptance.c +++ b/tests/editor_overlay_toggle_acceptance.c @@ -262,6 +262,77 @@ static int overlay_visible_from_chunk(tOverlayContext *pContext, return -1; } +/* E7-S3. Tower placement is core-authoritative, so aim directly at the world + * position E7-S2 reports rather than reconstructing it from the chunk. The + * base view deliberately has SHOW_SURFACES off and empty class masks: any + * changed pixel therefore proves the tower-class visibility exception made + * the marker independently visible. */ +static int tower_marker_visible_from_position( + tOverlayContext *pContext, + const tEdTowerInfo *pTower, + const tEdOverlayState *pBaseState, + const tEdOverlayState *pMarkedState, + size_t *puiDifference) +{ + static const float afYaw[] = { 0.0f, 90.0f, 180.0f, 270.0f }; + static const float afPitch[] = { -35.0f, -20.0f, 0.0f, 20.0f }; + static const float afDistance[] = { 1000.0f, 4000.0f }; + const float fRadiansPerDegree = 3.14159265358979f / 180.0f; + + *puiDifference = 0u; + for (size_t iDistance = 0; + iDistance < sizeof(afDistance) / sizeof(afDistance[0]); + iDistance++) { + for (size_t iYaw = 0; iYaw < sizeof(afYaw) / sizeof(afYaw[0]); + iYaw++) { + float fRadians = afYaw[iYaw] * fRadiansPerDegree; + + for (size_t iPitch = 0; + iPitch < sizeof(afPitch) / sizeof(afPitch[0]); iPitch++) { + tEdCameraState Camera = { + .uiStructSize = sizeof(Camera), + .uiVersion = ROLLER_ED_CAMERA_STATE_VERSION, + .fPosition = { + pTower->fWorldPosition[0] + - cosf(fRadians) * afDistance[iDistance], + pTower->fWorldPosition[1] + - sinf(fRadians) * afDistance[iDistance], + pTower->fWorldPosition[2] + + afDistance[iDistance] * 0.25f + }, + .fYawDegrees = afYaw[iYaw], + .fPitchDegrees = afPitch[iPitch] + }; + + if (RollerEd_SetCamera(&Camera) != ROLLER_ED_RESULT_OK) { + acceptance_error(pContext, "RollerEd_SetCamera failed"); + return 0; + } + if (!render_with_overlay(pContext, pBaseState, s_pMarkerBase) + || !render_with_overlay( + pContext, pMarkedState, s_pFrame)) + return 0; + *puiDifference = differing_pixels(s_pFrame, s_pMarkerBase); + if (*puiDifference == 0u) + continue; + if (!render_with_overlay(pContext, pBaseState, s_pFrame)) + return 0; + if (memcmp(s_pFrame, s_pMarkerBase, FRAME_BYTES) != 0) { + acceptance_fail( + pContext, + "clearing the tower marker on chunk %u left %zu " + "pixels drawn", + pTower->uiChunkId, + differing_pixels(s_pFrame, s_pMarkerBase)); + return 0; + } + return -1; + } + } + } + return -1; +} + static int SDLCALL overlay_worker(void *pUserData) { tOverlayContext *pContext = (tOverlayContext *)pUserData; @@ -641,6 +712,87 @@ static int SDLCALL overlay_worker(void *pUserData) uiStunts, uiFirstStuntChunk, auiMarkerDifference[1]); } + /* + * E7-S3. Every loaded tower is aimed at by the E7-S2-reported world + * position, including modes the game would suppress with iEnabled <= -1. + * The base has no surfaces and no class bits, proving the new flag alone + * owns visibility. The last marker is then selected by its anchor chunk; + * its outline must change the marker without any track surface present. + */ + { + uint32_t uiTowerCount = 0u; + uint32_t uiSelectedChunk = ROLLER_ED_INVALID_CHUNK_ID; + size_t uiSelectedMarkerPixels = 0u; + tEdOverlayState TowerBase = make_overlay(0u, 0u, 0u); + tEdOverlayState TowerMarkers = make_overlay( + ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS, 0u, 0u); + + if (RollerEd_QueryTowerCount(&uiTowerCount) != ROLLER_ED_RESULT_OK) { + acceptance_error(pContext, "RollerEd_QueryTowerCount failed"); + goto shutdown; + } + if (uiTowerCount == 0u) { + acceptance_fail(pContext, + "the E7-S3 retail fixture contains no towers"); + goto shutdown; + } + for (uint32_t uiTower = 0u; uiTower < uiTowerCount; uiTower++) { + tEdTowerInfo Info = { + .uiStructSize = sizeof(Info), + .uiVersion = ROLLER_ED_TOWER_INFO_VERSION + }; + size_t uiDifference = 0u; + + if (RollerEd_QueryTower(uiTower, &Info) != ROLLER_ED_RESULT_OK) { + acceptance_error(pContext, "RollerEd_QueryTower failed"); + goto shutdown; + } + if (!tower_marker_visible_from_position( + pContext, &Info, &TowerBase, &TowerMarkers, + &uiDifference)) + goto shutdown; + if (uiDifference == 0u) { + acceptance_fail( + pContext, + "tower %u on chunk %u drew nothing at its queried " + "position", + uiTower, Info.uiChunkId); + goto shutdown; + } + uiSelectedChunk = Info.uiChunkId; + uiSelectedMarkerPixels = uiDifference; + } + + if (!render_with_overlay(pContext, &TowerMarkers, s_pMarkerBase)) + goto shutdown; + TowerMarkers.uiFlags |= ROLLER_ED_OVERLAY_HIGHLIGHT_SELECTION; + TowerMarkers.uiFirstSelectedChunk = uiSelectedChunk; + TowerMarkers.uiLastSelectedChunk = uiSelectedChunk; + if (!render_with_overlay(pContext, &TowerMarkers, s_pFrame)) + goto shutdown; + if (memcmp(s_pFrame, s_pMarkerBase, FRAME_BYTES) == 0) { + acceptance_fail( + pContext, + "selecting tower chunk %u did not highlight its marker", + uiSelectedChunk); + goto shutdown; + } + TowerMarkers.uiFlags &= + ~(uint32_t)ROLLER_ED_OVERLAY_HIGHLIGHT_SELECTION; + if (!render_with_overlay(pContext, &TowerMarkers, s_pFrame)) + goto shutdown; + if (memcmp(s_pFrame, s_pMarkerBase, FRAME_BYTES) != 0) { + acceptance_fail( + pContext, + "clearing the tower selection left %zu pixels highlighted", + differing_pixels(s_pFrame, s_pMarkerBase)); + goto shutdown; + } + printf("tower markers: %u queried positions rendered; chunk %u " + "covered %zu pixels and highlighted\n", + uiTowerCount, uiSelectedChunk, uiSelectedMarkerPixels); + } + /* * E3A-S6. The test car stands on the selection's first chunk, so it is * aimed at the same way a marker is. Two things beyond "it draws": every diff --git a/tests/editor_reload_soak_acceptance.c b/tests/editor_reload_soak_acceptance.c index f14a6d96..cf7218bb 100644 --- a/tests/editor_reload_soak_acceptance.c +++ b/tests/editor_reload_soak_acceptance.c @@ -369,6 +369,25 @@ static int soak_write_text_line(FILE *pFile, const uint8_t *pbyLine, && fputs("\r\n", pFile) != EOF; } +static int soak_tower_zoom(int iChunk) +{ + return (iChunk % 25) / 5; +} + +static int soak_tower_mode_nibble(int iChunk) +{ + static const int aiModeNibbles[] = { 0, 1, 3, 4, 5 }; + + return aiModeNibbles[iChunk % 5]; +} + +static int soak_tower_enabled(int iChunk) +{ + static const int aiEnabledModes[] = { -1, -4, -2, -5, -3 }; + + return aiEnabledModes[iChunk % 5]; +} + static int soak_write_tower_surface_record(FILE *pFile, const uint8_t *pbyLine, size_t uiLineLength, @@ -399,7 +418,10 @@ static int soak_write_tower_surface_record(FILE *pFile, if (iField != 0 && fputc(' ', pFile) == EOF) return 0; if (iField == 12) { - if (fputs("256", pFile) == EOF) + int iSignType = 256 + 16 * soak_tower_zoom(iChunk) + + soak_tower_mode_nibble(iChunk); + + if (fprintf(pFile, "%d", iSignType) < 0) return 0; } else if (iField == 13) { if (fprintf(pFile, "%d", iChunk) < 0) @@ -419,7 +441,9 @@ static int soak_write_tower_surface_record(FILE *pFile, * E7-S1. Build this at runtime so the Valgrind soak exercises the real * staged-loader and facade path with more authored towers than the legacy * fixed table can hold. The two tower-authored chunks past capacity remain - * valid track data; only their decoded runtime towers are omitted. + * valid track data; only their decoded runtime towers are omitted. The first + * 25 entries cover every E7-S3 camera-mode/zoom combination while retaining + * the same 34-tower overflow boundary. */ static int soak_build_tower_limit_input(tSoakContext *pContext) { @@ -541,8 +565,8 @@ static int soak_verify_tower_limit(tSoakContext *pContext) if (TowerBase[iTower].iChunkIdx != iTower || TowerBase[iTower].iHOffset != iTower || TowerBase[iTower].iVOffset != -iTower - || TowerBase[iTower].iEnabled != -1 - || TowerBase[iTower].iTowerType != 0 + || TowerBase[iTower].iEnabled != soak_tower_enabled(iTower) + || TowerBase[iTower].iTowerType != soak_tower_zoom(iTower) || TowerSect[iTower] != iTower) { soak_fail(pContext, "tower %d did not preserve its decoded fields", @@ -594,7 +618,8 @@ static int soak_verify_tower_limit(tSoakContext *pContext) return 0; } } - printf(" E7-S1/S2: queried the first %d of %d authored towers\n", + printf(" E7-S1/S2/S3: queried the first %d of %d authored towers " + "across all 25 mode/zoom combinations\n", NumTowers, SOAK_TOWER_INPUT_COUNT); return 1; } diff --git a/tests/test_e3a_s1_overlay_state.py b/tests/test_e3a_s1_overlay_state.py index f5df4cd0..9bc8b609 100644 --- a/tests/test_e3a_s1_overlay_state.py +++ b/tests/test_e3a_s1_overlay_state.py @@ -17,6 +17,7 @@ "ROLLER_ED_OVERLAY_SHOW_STUNT_MARKERS", "ROLLER_ED_OVERLAY_SHOW_TEST_CAR", "ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH", + "ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS", ) diff --git a/tests/test_e3a_s4_helper_overlays.py b/tests/test_e3a_s4_helper_overlays.py index 28d58e5a..2f23318f 100644 --- a/tests/test_e3a_s4_helper_overlays.py +++ b/tests/test_e3a_s4_helper_overlays.py @@ -95,8 +95,9 @@ def test_each_helper_has_its_own_flag(self) -> None: "ROLLER_ED_OVERLAY_SHOW_CENTER_LINE", ): self.assertIn(flag, body) - # Two here plus E3A-S5's two markers; every helper is its own flag. - self.assertEqual(body.count("roller_ed_overlay_enabled("), 4) + # Two here, E3A-S5's two markers, and E7-S3's tower markers; every + # helper remains independently gated. + self.assertEqual(body.count("roller_ed_overlay_enabled("), 5) def test_all_four_ai_lines_are_drawn(self) -> None: body = function_body(self.draw, "void drawtrk3_editor_draw_helpers(") diff --git a/tests/test_e7_s3_tower_markers.py b/tests/test_e7_s3_tower_markers.py new file mode 100644 index 00000000..62b7e605 --- /dev/null +++ b/tests/test_e7_s3_tower_markers.py @@ -0,0 +1,191 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ROLLER = ROOT / "PROJECTS" / "ROLLER" + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[brace + 1 : index] + raise AssertionError(f"unterminated function: {signature}") + + +def without_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) + return re.sub(r"//.*", "", source) + + +class TowerMarkerFacadeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.api = (ROLLER / "editor_api.h").read_text(encoding="ascii") + cls.overlay_h = (ROLLER / "editor_overlay.h").read_text( + encoding="ascii" + ) + cls.overlay_c = (ROLLER / "editor_overlay.c").read_text( + encoding="ascii" + ) + + def test_bit_12_is_public_and_accepted_without_a_layout_bump(self) -> None: + self.assertRegex( + self.api, + r"ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS\s*=\s*1u\s*<<\s*12", + ) + self.assertIn( + "ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS", + self.overlay_h[ + self.overlay_h.index("ROLLER_ED_OVERLAY_KNOWN_FLAGS") : + self.overlay_h.index("ROLLER_ED_OVERLAY_DEFAULT_FLAGS") + ], + ) + self.assertIn("ROLLER_ED_OVERLAY_STATE_VERSION 3u", self.api) + self.assertIn("sizeof(tEdOverlayState) == 36u", self.api) + + def test_marker_visibility_ignores_surface_master_and_class_mask(self) -> None: + body = without_comments( + function_body( + self.overlay_c, + "bool roller_ed_overlay_surface_class_visible(", + ) + ) + tower = body.index("ROLLER_ED_SURFACE_CLASS_TOWER") + marker = body.index("ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS") + surfaces = body.index("ROLLER_ED_OVERLAY_SHOW_SURFACES") + mask = body.index("overlay_class_selected") + self.assertLess(tower, marker) + self.assertLess(marker, surfaces) + self.assertLess(marker, mask) + + +class SharedTowerBillboardTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tower_h = (ROLLER / "tower.h").read_text(encoding="ascii") + cls.tower_c = (ROLLER / "tower.c").read_text(encoding="ascii") + cls.draw = (ROLLER / "drawtrk3.c").read_text(encoding="utf-8") + + def test_drawtower_delegates_its_added_body_to_the_shared_seam(self) -> None: + self.assertIn( + "void tower_emit_marker(int iTowerIdx, float fScale);", + self.tower_h, + ) + legacy = without_comments( + function_body(self.tower_c, "void DrawTower(") + ) + for gate in ("NearTow", "iEnabled > -1", "fOriginalZ", "xp", "yp"): + self.assertIn(gate, legacy) + self.assertIn("tower_emit_marker(iTowerIdx, 1.0f);", legacy) + self.assertNotIn("drawtrk3_emit_surface_to_renderer", legacy) + + def test_the_seam_is_the_original_camera_facing_surface(self) -> None: + seam = without_comments( + function_body(self.tower_c, "void tower_emit_marker(") + ) + for value in ( + "TowerX[iTowerIdx]", + "TowerY[iTowerIdx]", + "TowerZ[iTowerIdx]", + "vk1", + "vk2", + "vk4", + "vk5", + "vk7", + "vk8", + "scr_size", + "VIEWDIST", + "fScale", + "SURFACE_FLAG_FLIP_BACKFACE | 0xE7", + "ROLLER_ED_SURFACE_CLASS_TOWER", + "ROLLER_ED_CONTENT_RUNTIME_SCENERY", + "drawtrk3_emit_surface_to_renderer", + ): + self.assertIn(value, seam) + self.assertIn("TowerBase[iTowerIdx].iChunkIdx", seam) + self.assertNotIn("NearTow", seam) + self.assertNotIn("iEnabled", seam) + + def test_editor_draws_every_tower_after_existing_markers(self) -> None: + helpers = without_comments( + function_body(self.draw, "void drawtrk3_editor_draw_helpers(") + ) + tower_flag = helpers.index("ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS") + self.assertLess( + helpers.index("ROLLER_ED_OVERLAY_SHOW_STUNT_MARKERS"), tower_flag + ) + self.assertIn("iTowerIdx < NumTowers", helpers[tower_flag:]) + self.assertIn("tower_emit_marker(iTowerIdx, 1.0f)", helpers[tower_flag:]) + self.assertNotIn("DrawTower(", helpers) + + def test_game_dispatch_remains_dead_and_canonical_export_ignores_markers( + self, + ) -> None: + self.assertNotIn("render_queue_3d_add_tower", self.draw) + canonical = without_comments( + function_body(self.draw, "bool drawtrk3_emit_full_track(") + ) + self.assertNotIn("DrawTower(", canonical) + self.assertNotIn("tower_emit_marker(", canonical) + self.assertEqual( + self.tower_c.count("drawtrk3_emit_surface_to_renderer("), 1 + ) + + +class TowerMarkerAcceptanceTests(unittest.TestCase): + def test_native_seam_fixture_covers_every_mode_and_zoom(self) -> None: + native = (ROOT / "tests" / "tower_marker_test.c").read_text( + encoding="ascii" + ) + self.assertIn("const uint32_t uiFixtureTowers = 25u", native) + self.assertIn("{ -1, -4, -2, -5, -3 }", native) + self.assertIn("i / 5u", native) + self.assertIn("tower_emit_marker((int)i, 1.0f)", native) + self.assertIn("fCentreX", native) + self.assertIn("fCentreY", native) + self.assertIn("fCentreZ", native) + self.assertIn("ROLLER_ED_SURFACE_CLASS_TOWER", native) + self.assertIn("ROLLER_ED_CONTENT_RUNTIME_SCENERY", native) + + soak = (ROOT / "tests" / "editor_reload_soak_acceptance.c").read_text( + encoding="ascii" + ) + self.assertIn("static const int aiModeNibbles[] = { 0, 1, 3, 4, 5 }", soak) + self.assertIn("static const int aiEnabledModes[] = { -1, -4, -2, -5, -3 }", soak) + self.assertIn("return (iChunk % 25) / 5", soak) + + def test_retail_renderer_aims_at_every_queried_position(self) -> None: + acceptance = ( + ROOT / "tests" / "editor_overlay_toggle_acceptance.c" + ).read_text(encoding="ascii") + self.assertIn("RollerEd_QueryTowerCount(&uiTowerCount)", acceptance) + self.assertIn("RollerEd_QueryTower(uiTower, &Info)", acceptance) + self.assertIn("pTower->fWorldPosition[0]", acceptance) + self.assertIn("pTower->fWorldPosition[1]", acceptance) + self.assertIn("pTower->fWorldPosition[2]", acceptance) + self.assertIn("ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS, 0u, 0u", acceptance) + self.assertIn("ROLLER_ED_OVERLAY_HIGHLIGHT_SELECTION", acceptance) + + def test_overlay_changes_remain_view_only(self) -> None: + api = (ROLLER / "editor_api.c").read_text(encoding="ascii") + body = without_comments( + function_body( + api, + "eRollerEdResult ROLLER_ED_CALL RollerEd_SetOverlayState(", + ) + ) + self.assertNotIn("roller_ed_advance_geometry_epoch", body) + self.assertNotIn("roller_ed_release_geometry_cache", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_editor_api_contract.py b/tests/test_editor_api_contract.py index 429c9c24..d0fe0901 100644 --- a/tests/test_editor_api_contract.py +++ b/tests/test_editor_api_contract.py @@ -50,6 +50,7 @@ def test_public_result_and_geometry_contract_is_complete(self) -> None: "fAtlasBias", "ROLLER_ED_OVERLAY_SHOW_SURFACES", "ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH", + "ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS", ): self.assertIn(name, self.header) diff --git a/tests/tower_marker_test.c b/tests/tower_marker_test.c new file mode 100644 index 00000000..fc1423bb --- /dev/null +++ b/tests/tower_marker_test.c @@ -0,0 +1,173 @@ +#include "3d.h" +#include "drawtrk3.h" +#include "loadtrak.h" +#include "tower.h" +#include "transfrm.h" +#include "view.h" + +#include +#include +#include +#include + +GameRenderer *g_pGameRenderer = (GameRenderer *)(uintptr_t)1u; +tData localdata[MAX_TRACK_CHUNKS]; +int TRAK_LEN; +int scr_size = 64; +int ybase = 100; +int xbase = 159; +float viewx; +float viewy; +float viewz; +int VIEWDIST = 100; +float vk1 = 1.0f; +int xp; +float vk2; +float vk3; +float vk4; +float vk5 = 1.0f; +float vk6; +float vk7; +float vk8; +float vk9 = 1.0f; +int yp; +int NearTow = -1; + +typedef struct +{ + GameRenderVertex aVertices[ED_SURFACE_VERTEX_COUNT]; + tEdSurfaceInfo Info; +} tCapturedTowerMarker; + +static tCapturedTowerMarker s_aCaptured[MAX_TOWERS]; +static uint32_t s_uiCaptured; + +bool drawtrk3_emit_surface_to_renderer( + GameRenderer *pRenderer, + const GameRenderVertex aVertices[ED_SURFACE_VERTEX_COUNT], + const tEdSurfaceInfo *pInfo) +{ + tCapturedTowerMarker *pCaptured; + + if (pRenderer != g_pGameRenderer || !aVertices || !pInfo + || s_uiCaptured >= MAX_TOWERS) + return false; + pCaptured = &s_aCaptured[s_uiCaptured++]; + memcpy(pCaptured->aVertices, aVertices, sizeof(pCaptured->aVertices)); + pCaptured->Info = *pInfo; + return true; +} + +static int check(int bCondition, int iLine) +{ + if (!bCondition) + fprintf(stderr, "tower marker check failed at line %d\n", iLine); + return bCondition ? 0 : iLine; +} + +#define CHECK(condition) \ + do { \ + int iResult = check((condition), __LINE__); \ + if (iResult != 0) \ + return iResult; \ + } while (0) + +static int nearly_equal(float fLeft, float fRight) +{ + return fabsf(fLeft - fRight) <= 0.0001f; +} + +static void reset_capture(void) +{ + memset(s_aCaptured, 0, sizeof(s_aCaptured)); + s_uiCaptured = 0u; +} + +int main(int argc, const char **argv, const char **envp) +{ + static const int aiModes[] = { -1, -4, -2, -5, -3 }; + const uint32_t uiFixtureTowers = 25u; + + (void)argc; + (void)argv; + (void)envp; + + NumTowers = (int)uiFixtureTowers; + for (uint32_t i = 0u; i < uiFixtureTowers; i++) { + TowerBase[i].iChunkIdx = (int)(100u + i); + TowerBase[i].iEnabled = aiModes[i % 5u]; + TowerBase[i].iTowerType = (int)(i / 5u); + TowerX[i] = 10.0f + (float)i * 3.0f; + TowerY[i] = -20.0f + (float)i * 5.0f; + TowerZ[i] = 1000.0f + (float)i * 100.0f; + } + + /* Every mode/zoom pair bypasses DrawTower's game gates and emits at the + * E7-S2 world position with its anchor chunk identity. */ + reset_capture(); + NearTow = 0; + for (uint32_t i = 0u; i < uiFixtureTowers; i++) + tower_emit_marker((int)i, 1.0f); + CHECK(s_uiCaptured == uiFixtureTowers); + for (uint32_t i = 0u; i < uiFixtureTowers; i++) { + const tCapturedTowerMarker *pMarker = &s_aCaptured[i]; + float fCentreX = (pMarker->aVertices[0].x + + pMarker->aVertices[2].x) * 0.5f; + float fCentreY = (pMarker->aVertices[0].y + + pMarker->aVertices[2].y) * 0.5f; + float fCentreZ = (pMarker->aVertices[0].z + + pMarker->aVertices[2].z) * 0.5f; + + CHECK(nearly_equal(fCentreX, TowerX[i])); + CHECK(nearly_equal(fCentreY, TowerY[i])); + CHECK(nearly_equal(fCentreZ, TowerZ[i])); + CHECK(pMarker->Info.uiChunkId == (uint32_t)TowerBase[i].iChunkIdx); + CHECK(pMarker->Info.uiRenderFlags + == (SURFACE_FLAG_FLIP_BACKFACE | 0xE7)); + CHECK(pMarker->Info.uiBackSurfaceFlags == ED_MATERIAL_ID_NONE); + CHECK(pMarker->Info.uiTextureSet == TEXTURE_BANK_TRACK); + CHECK(pMarker->Info.unSurfaceClass + == ROLLER_ED_SURFACE_CLASS_TOWER); + CHECK(pMarker->Info.unContentClass + == ROLLER_ED_CONTENT_RUNTIME_SCENERY); + CHECK(pMarker->Info.byTopology == ROLLER_ED_TOPOLOGY_QUAD); + } + + /* fScale changes only the constant-pixel billboard extent. */ + reset_capture(); + tower_emit_marker(0, 1.0f); + tower_emit_marker(0, 2.0f); + CHECK(s_uiCaptured == 2u); + CHECK(nearly_equal( + s_aCaptured[1].aVertices[0].x - TowerX[0], + 2.0f * (s_aCaptured[0].aVertices[0].x - TowerX[0]))); + CHECK(nearly_equal( + s_aCaptured[1].aVertices[0].y - TowerY[0], + 2.0f * (s_aCaptured[0].aVertices[0].y - TowerY[0]))); + + /* Invalid direct calls are inert instead of indexing the fixed tables. */ + reset_capture(); + tower_emit_marker(-1, 1.0f); + tower_emit_marker(NumTowers, 1.0f); + tower_emit_marker(0, 0.0f); + tower_emit_marker(0, NAN); + CHECK(s_uiCaptured == 0u); + + /* DrawTower retains the game's NearTow and enabled gates, then delegates + * the surviving legacy path to the same scale-1 seam. */ + TowerBase[0].iEnabled = 0; + NearTow = -1; + reset_capture(); + DrawTower(0, NULL); + CHECK(s_uiCaptured == 1u); + TowerBase[0].iEnabled = -1; + DrawTower(0, NULL); + CHECK(s_uiCaptured == 1u); + TowerBase[0].iEnabled = 0; + NearTow = 0; + DrawTower(0, NULL); + CHECK(s_uiCaptured == 1u); + + puts("tower marker seam tests passed"); + return 0; +}