diff --git a/docs/STABILIZATION.md b/docs/STABILIZATION.md new file mode 100644 index 0000000..2467ff7 --- /dev/null +++ b/docs/STABILIZATION.md @@ -0,0 +1,67 @@ +# Stabilization roadmap + +Stages are delivered separately. Each completed stage is tested, committed and +pushed before work stops for confirmation. Editor and runtime UI redesign will +use an Unreal Engine 5-inspired visual style; stage 1 does not redesign UI. + +## Stage 1 — correctness and data safety + +Implemented: + +- `DrawRectOutline` keeps loop counters in a callee-saved register, not the + volatile registers overwritten by `DrawPixel`. Nonpositive sizes draw nothing. +- `TraceShotWall` traverses current map cells independently of rendering. Distances + are 24.8 world units; closed doors, glass, portals and out-of-map cells block + shots. Open doors do not. Corner-adjacent walls block diagonal seam shots. +- Hitscan chooses the nearest live guard intersecting the forward billboard + hitbox. Half-width is 16 world units times authored scale (zero scale means + legacy 1.0), not a constant number of screen pixels. +- `IsGameplayBlocked` centralizes gameplay gating. `UpdatePlayer` latches blocked + ticks before processing cinematic state. AI, pickups and weapon timers freeze + during active/pending cutscenes and room fades. END/skip and instant transitions + cannot resume AI or fire stale input halfway through a tick. Death blocks player + movement, turning and firing; lethal damage stops the remaining entity pass. +- Packed entity metadata remains binary-compatible. Only `flags == 0` enables + legacy texture-based archetypes; any nonzero flags explicitly encode the low + type byte, including Prop. Health uses one byte: 1..255, zero = legacy 100 HP. + The C++ setter clamps before narrowing, and the inspector exposes 1..255. +- IDE source writes use a unique sibling temporary file, checked full write, + flush, close and atomic replacement. Failure preserves disk contents and dirty + buffer. Loading publishes a new document only after a complete valid text read. + File switches and builds stop on failed autosave. Saves target the file actually + opened, not a newly resolved fallback path. +- Compiler output is drained even with the IDE hidden. Process startup uses the + system command processor with quoting for project paths containing spaces. + +### Verification + +Windows commands: + +```cmd +build.bat +editor\build_editor.bat +python tools\test_regressions.py +``` + +Results: editor **112/112**, ASM **148/148**. The suite also checks live x86/x64 +preview IPC, DX11 pixel equality, cinematic trace equality and unchanged project +assets. New tests exercise production gameplay and IDE procedures, including +failed atomic replacement under a Windows sharing lock and hidden compiler output +exceeding pipe capacity. Interactive UI playtesting remains separate. + +## Remaining stages + +2. Predictable simulation: fixed 60 Hz ticks, fractional movement, normalized + input, complete collision shape, current-room restart/reset policy. Extend + gameplay gating into explicit states and separate cinematic camera ownership. +3. Honest editor/gameplay: complete undo, keys/switches and supported interactions; + UE5-inspired editor/runtime UI redesign. +4. Project lifecycle and validated, safe standalone export. +5. Measurements and targeted renderer/editor optimizations. +6. Finished multi-room demo game and release verification. +7. Data-driven extensions and subsequent rendering features. + +Stage 1 intentionally does not claim that all audit findings are fixed: restart +semantics, fixed timing, collision/AI visibility, full undo and exporter safety +remain assigned to subsequent stages. Existing map and texture assets are not +migrated or rewritten. diff --git a/editor/editor.exe b/editor/editor.exe index 2251c98..2d62450 100644 Binary files a/editor/editor.exe and b/editor/editor.exe differ diff --git a/editor/src/editor_app.cpp b/editor/src/editor_app.cpp index e4c52f4..c4d3501 100644 --- a/editor/src/editor_app.cpp +++ b/editor/src/editor_app.cpp @@ -2102,18 +2102,18 @@ void EditorApp::RenderInspector() { if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) { PushUndo(); - SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); + SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } if (archType == ENTITY_ARCHETYPE_GUARD) { - if (ImGui::SliderInt("Здоровье (HP)", &hp, 10, 500)) { - SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); + if (ImGui::SliderInt("Здоровье (HP)", &hp, 1, 255)) { + SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } if (ImGui::SliderInt("Скорость", &spd, 1, 8)) { - SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); + SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) { - SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); + SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } } else if (archType == ENTITY_ARCHETYPE_MEDKIT) { ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе"); diff --git a/editor/src/editor_code.inl b/editor/src/editor_code.inl index 49f7b97..5b8578b 100644 --- a/editor/src/editor_code.inl +++ b/editor/src/editor_code.inl @@ -93,6 +93,7 @@ static const int g_ScriptTemplateCount = sizeof(g_ScriptTemplates) / sizeof(g_Sc struct CodeEditorState { int currentFile = 0; std::string fileContent; + fs::path loadedPath; std::vector textBuffer; bool isModified = false; std::string statusText = "Готов к редактированию кода MASM32"; @@ -120,50 +121,64 @@ struct CodeEditorState { void LoadFile(int fileIndex, const std::string& projectRoot) { if (fileIndex < 0 || fileIndex >= g_CodeFileCount) return; - currentFile = fileIndex; + if (isModified && !SaveFile(projectRoot)) return; fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[fileIndex].relativePath; if (!fs::exists(fullPath)) { fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[fileIndex].relativePath; } HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile != INVALID_HANDLE_VALUE) { - DWORD fileSize = GetFileSize(hFile, nullptr); - if (fileSize != INVALID_FILE_SIZE) { - EnsureBufferSize(fileSize); - DWORD read = 0; - ReadFile(hFile, textBuffer.data(), fileSize, &read, nullptr); - textBuffer[read] = '\0'; - fileContent = std::string(textBuffer.data(), read); - isModified = false; - statusText = "Открыт файл: " + g_ProjectCodeFiles[fileIndex].displayName; - } - CloseHandle(hFile); - } else { - fileContent.clear(); - EnsureBufferSize(1024); - textBuffer[0] = '\0'; - isModified = false; + if (hFile == INVALID_HANDLE_VALUE) { statusText = "Не удалось открыть файл: " + fullPath.string(); + return; } + LARGE_INTEGER size{}; + bool ok = GetFileSizeEx(hFile, &size) && size.QuadPart >= 0 && size.QuadPart <= 16 * 1024 * 1024; + std::vector incoming(ok ? static_cast(size.QuadPart) + 1 : 1, 0); + DWORD read = 0; + if (ok) ok = ReadFile(hFile, incoming.data(), static_cast(size.QuadPart), &read, nullptr) && + read == static_cast(size.QuadPart); + CloseHandle(hFile); + if (!ok || std::memchr(incoming.data(), '\0', read)) { + statusText = "Ошибка чтения исходного файла: " + fullPath.string(); + return; + } + // Publish a document only after a complete read. Failed loads keep the old buffer. + currentFile = fileIndex; + loadedPath = fs::absolute(fullPath); + fileContent.assign(incoming.data(), read); + textBuffer = std::move(incoming); + EnsureBufferSize(read); + isModified = false; + statusText = "Открыт файл: " + g_ProjectCodeFiles[fileIndex].displayName; } bool SaveFile(const std::string& projectRoot) { - if (currentFile < 0 || currentFile >= g_CodeFileCount) return false; - fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[currentFile].relativePath; - if (!fs::exists(fullPath)) { - fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[currentFile].relativePath; + if (currentFile < 0 || currentFile >= g_CodeFileCount || loadedPath.empty() || textBuffer.empty()) { + statusText = "Сначала откройте исходный файл."; + return false; } - - HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + const auto fullPath = loadedPath; + static unsigned serial = 0; + const auto temporary = fullPath.string() + ".tmp." + std::to_string(GetCurrentProcessId()) + "." + std::to_string(++serial); + HANDLE hFile = CreateFileA(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) { statusText = "Ошибка сохранения: доступ запрещен (" + fullPath.string() + ")"; return false; } DWORD len = static_cast(std::strlen(textBuffer.data())); DWORD written = 0; - WriteFile(hFile, textBuffer.data(), len, &written, nullptr); - CloseHandle(hFile); + bool ok = WriteFile(hFile, textBuffer.data(), len, &written, nullptr) && written == len; + if (ok) ok = FlushFileBuffers(hFile) != FALSE; + if (!CloseHandle(hFile)) ok = false; + if (ok) ok = MoveFileExA(temporary.c_str(), fullPath.string().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != FALSE; + if (!ok) { + DeleteFileA(temporary.c_str()); + statusText = "Ошибка сохранения; исходный файл сохранен: " + fullPath.string(); + return false; + } + fileContent.assign(textBuffer.data(), len); isModified = false; statusText = "Файл сохранен: " + g_ProjectCodeFiles[currentFile].displayName; return true; @@ -173,7 +188,7 @@ struct CodeEditorState { if (isBuilding) return; // Auto-save current file before building - SaveFile(projectRoot); + if (!SaveFile(projectRoot)) return; buildLog.clear(); buildErrors.clear(); @@ -214,9 +229,12 @@ struct CodeEditorState { si.wShowWindow = SW_HIDE; PROCESS_INFORMATION pi{}; - std::string cmd = "cmd.exe /c \"" + buildBat.string() + "\""; + char commandProcessor[MAX_PATH]{}; + GetSystemDirectoryA(commandProcessor, MAX_PATH); + const auto cmdPath = fs::path(commandProcessor) / "cmd.exe"; + std::string cmd = "\"" + cmdPath.string() + "\" /d /s /c \"\"" + buildBat.string() + "\"\""; - if (CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, root.string().c_str(), &si, &pi)) { + if (CreateProcessA(cmdPath.string().c_str(), cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, root.string().c_str(), &si, &pi)) { CloseHandle(pi.hThread); hBuildProcess = pi.hProcess; isBuilding = true; @@ -225,6 +243,8 @@ struct CodeEditorState { buildLog = "[ERROR] Не удалось запустить процесс сборки.\n"; hasBuildResult = true; buildExitCode = 1; + CloseHandle(hReadPipe); + hReadPipe = nullptr; } CloseHandle(hWritePipe); } @@ -316,9 +336,9 @@ struct CodeEditorState { static CodeEditorState g_CodeEditor; void EditorApp::RenderCodeEditorWindow() { - if (!showCodeEditorModal) return; - + // Drain compiler output even when the IDE is hidden; a full pipe blocks ml/link. g_CodeEditor.PollBuild(); + if (!showCodeEditorModal) return; ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) { @@ -408,9 +428,6 @@ void EditorApp::RenderCodeEditorWindow() { for (int i = 0; i < g_CodeFileCount; ++i) { bool isSelected = (g_CodeEditor.currentFile == i); if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) { - if (g_CodeEditor.isModified) { - g_CodeEditor.SaveFile(root); - } g_CodeEditor.LoadFile(i, root); } } @@ -512,7 +529,6 @@ void EditorApp::RenderCodeEditorWindow() { for (int f = 0; f < g_CodeFileCount; ++f) { if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos || g_ProjectCodeFiles[f].relativePath.find(err.file) != std::string::npos) { - if (g_CodeEditor.isModified) g_CodeEditor.SaveFile(root); g_CodeEditor.LoadFile(f, root); break; } diff --git a/editor/src/level_format.h b/editor/src/level_format.h index 7a3c6b3..4389155 100644 --- a/editor/src/level_format.h +++ b/editor/src/level_format.h @@ -103,7 +103,7 @@ struct SpriteEntry { inline uint8_t GetSpriteEntityType(const SpriteEntry& s) { uint8_t t = static_cast(s.flags & 0xFF); - if (t == 0) { + if (s.flags == 0) { if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD; if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT; if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO; @@ -122,7 +122,10 @@ inline uint8_t GetSpriteAggro(const SpriteEntry& s) { uint8_t agg = static_cast((s.flags >> 24) & 0xFF); return (agg == 0) ? 6 : agg; } -inline void SetSpriteEntityData(SpriteEntry& s, uint8_t type, uint8_t health, uint8_t speed, uint8_t aggro) { +// Clamp before narrowing: callers must not silently wrap values such as 256 HP. +// Zero remains the legacy default encoding for health, speed and aggro. +inline void SetSpriteEntityData(SpriteEntry& s, uint8_t type, int health, uint8_t speed, uint8_t aggro) { + health = health < 0 ? 0 : (health > 255 ? 255 : health); s.flags = static_cast(type) | (static_cast(health) << 8) | (static_cast(speed) << 16) | diff --git a/engine.exe b/engine.exe index 5c08f55..63585e8 100644 Binary files a/engine.exe and b/engine.exe differ diff --git a/include/engine_combat.inc b/include/engine_combat.inc index 814a07f..48dba13 100644 --- a/include/engine_combat.inc +++ b/include/engine_combat.inc @@ -10,6 +10,130 @@ DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD DrawNumber8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD +; Trace the current world along the player's forward direction. EAX = first +; blocking boundary in 24.8 world units. Glass blocks shots (unlike rendering). +; No framebuffer/depth-buffer dependencies. Out-of-map cells are solid. +TraceShotWall PROC uses ebx ecx edx esi edi + LOCAL cellX:DWORD, cellY:DWORD, stepX:DWORD, stepY:DWORD + LOCAL deltaX:DWORD, deltaY:DWORD, sideX:DWORD, sideY:DWORD + LOCAL hitDistance:DWORD, remaining:DWORD + + mov eax, playerX + sar eax, 6 + mov cellX, eax + mov eax, playerY + sar eax, 6 + mov cellY, eax + invoke GetMapCell, cellX, cellY + invoke IsSolidCell, eax + test eax, eax + jnz shot_wall_at_origin + + mov eax, playerAngle + and eax, ANGLE_MASK + mov esi, [tableCos+eax*4] + mov edi, [tableSin+eax*4] + mov deltaX, 3FFFFFFFh + mov sideX, 3FFFFFFFh + mov stepX, 1 + test esi, esi + jz shot_setup_y + mov eax, esi + cdq + xor eax, edx + sub eax, edx + mov ecx, eax + mov eax, (64 shl 24) + xor edx, edx + div ecx + mov deltaX, eax + mov eax, playerX + and eax, 63 + test esi, esi + js shot_negative_x + neg eax + add eax, 64 + jmp shot_side_x +shot_negative_x: + mov stepX, -1 +shot_side_x: + imul deltaX + shrd eax, edx, 6 + mov sideX, eax +shot_setup_y: + mov deltaY, 3FFFFFFFh + mov sideY, 3FFFFFFFh + mov stepY, 1 + test edi, edi + jz shot_traverse_start + mov eax, edi + cdq + xor eax, edx + sub eax, edx + mov ecx, eax + mov eax, (64 shl 24) + xor edx, edx + div ecx + mov deltaY, eax + mov eax, playerY + and eax, 63 + test edi, edi + js shot_negative_y + neg eax + add eax, 64 + jmp shot_side_y +shot_negative_y: + mov stepY, -1 +shot_side_y: + imul deltaY + shrd eax, edx, 6 + mov sideY, eax +shot_traverse_start: + mov remaining, MAP_W+MAP_H+2 +shot_traverse: + mov eax, sideX + cmp eax, sideY + jb shot_step_x + jne shot_step_y + ; At a corner also test the X-adjacent cell: no shots through wall seams. + mov ebx, cellX + add ebx, stepX + invoke GetMapCell, ebx, cellY + invoke IsSolidCell, eax + test eax, eax + jz shot_step_y + mov eax, sideX + ret +shot_step_x: + mov hitDistance, eax + add eax, deltaX + mov sideX, eax + mov eax, stepX + add cellX, eax + jmp shot_test_cell +shot_step_y: + mov eax, sideY + mov hitDistance, eax + add eax, deltaY + mov sideY, eax + mov eax, stepY + add cellY, eax +shot_test_cell: + invoke GetMapCell, cellX, cellY + invoke IsSolidCell, eax + test eax, eax + jnz shot_wall_found + dec remaining + jnz shot_traverse + ; Defensive bound: a valid ray exits a 32x32 map within 64 crossings. +shot_wall_found: + mov eax, hitDistance + ret +shot_wall_at_origin: + xor eax, eax + ret +TraceShotWall ENDP + ; ------------------------------------------------------------------------------ ; UpdateCombat: Process weapon firing, hitscan raycasting, cooldowns & damage ; ------------------------------------------------------------------------------ @@ -18,7 +142,11 @@ UpdateCombat PROC uses ebx ecx edx esi edi LOCAL i:DWORD, closestIdx:DWORD, closestDist:DWORD LOCAL sprX:DWORD, sprY:DWORD LOCAL transX:DWORD, transY:DWORD - LOCAL sprScreenX:DWORD, wallDepth:DWORD + LOCAL targetHalfWidth:DWORD, wallDepth:DWORD + + call IsGameplayBlocked + test eax, eax + jnz combat_done ; 1. Decrement cooldowns cmp playerAttackCooldown, 0 @@ -63,8 +191,8 @@ UpdateCombat PROC uses ebx ecx edx esi edi mov edx, [tableSin + eax*4] mov camSin, edx - ; Center wall depth from depthBuffer[160] - mov eax, [depthBuffer + SCREEN_HALF_W * 4] + ; Query current geometry, never the previous rendered frame. + call TraceShotWall mov wallDepth, eax mov closestIdx, -1 @@ -117,8 +245,9 @@ hitscan_loop: cmp transY, 16 jle next_hitscan_target - ; Must be closer than the wall at center screen + ; Compare in 24.8 so a target on a wall boundary is not hit through it. mov eax, transY + shl eax, 8 cmp eax, wallDepth jge next_hitscan_target @@ -133,20 +262,21 @@ hitscan_loop: add ecx, eax mov transX, ecx - ; sprScreenX = (transX * PROJ_DIST) / transY + SCREEN_HALF_W + ; World-space billboard hitbox: 32 units wide at scale 1.0, scaled with + ; the authored sprite, never with screen distance. Zero means legacy 1.0. + mov eax, [ebx].SpriteStruct.scale + test eax, eax + jnz @F + mov eax, FIXED_ONE +@@: + imul eax, 16 + sar eax, 16 + mov targetHalfWidth, eax mov eax, transX - imul eax, PROJ_DIST - cdq - idiv transY - add eax, SCREEN_HALF_W - mov sprScreenX, eax - - ; Crosshair hitbox: within 22 pixels of center (SCREEN_HALF_W = 160) - sub eax, SCREEN_HALF_W cdq xor eax, edx - sub eax, edx ; abs(sprScreenX - 160) - cmp eax, 22 + sub eax, edx + cmp eax, targetHalfWidth jg next_hitscan_target ; Check if closer than previous target diff --git a/include/engine_data.inc b/include/engine_data.inc index f7ba2f7..b92dfd1 100644 --- a/include/engine_data.inc +++ b/include/engine_data.inc @@ -17,6 +17,9 @@ hEngineInstance dd 0 isRunning dd 1 ; High Precision Timing +; Latched by UpdatePlayer: scene/end/skip/instant-transition ticks never run AI +; or combat, even if they return control partway through the tick. +gameplayTickBlocked dd 0 targetFrameTimeMs dd 16 ; ~60 FPS (16.6 ms) lastTickCount dd 0 fpsCounter dd 0 diff --git a/include/engine_entity.inc b/include/engine_entity.inc index dfbc58c..9751c68 100644 --- a/include/engine_entity.inc +++ b/include/engine_entity.inc @@ -142,6 +142,9 @@ UpdateEntities PROC uses ebx ecx edx esi edi ret entities_active: + call IsGameplayBlocked + test eax, eax + jnz entities_done mov i, 0 entity_loop: @@ -376,6 +379,8 @@ check_guard_attack: test eax, eax jnz next_entity mov isPlayerDead, 1 + ; Nothing else may collect items or attack after a lethal hit this tick. + jmp entities_done next_entity: inc i diff --git a/include/engine_map.inc b/include/engine_map.inc index a6d1d54..acb2473 100644 --- a/include/engine_map.inc +++ b/include/engine_map.inc @@ -376,6 +376,9 @@ SwitchSublevel PROC uses esi edi, pszNextMap:DWORD, spawnX:DWORD, spawnY:DWORD, cmp fadeState, 0 jne switch_busy + ; Even an instant room switch consumes this tick. AI/combat must not use + ; pre-transition input in the newly loaded room. + mov gameplayTickBlocked, 1 mov esi, pszNextMap lea edi, pendingMapPath mov ecx, 32 diff --git a/include/engine_physics.inc b/include/engine_physics.inc index 641a906..81858dd 100644 --- a/include/engine_physics.inc +++ b/include/engine_physics.inc @@ -4,6 +4,17 @@ .code +; Shared gameplay gate. Returns EAX != 0 when simulation must be frozen. +; UpdatePlayer owns the per-tick latch; renderer never changes this decision. +IsGameplayBlocked PROC + mov eax, gameplayTickBlocked + or eax, isPlayerDead + or eax, isCutsceneActive + or eax, cutscenePending + or eax, fadeState + ret +IsGameplayBlocked ENDP + ; ------------------------------------------------------------------------------ ; UpdatePlayer: Updates rotation, computes movement vector & handles collisions ; ------------------------------------------------------------------------------ @@ -13,6 +24,9 @@ UpdatePlayer PROC uses ebx ecx edx esi edi LOCAL nextX:DWORD, nextY:DWORD LOCAL checkGridX:DWORD, checkGridY:DWORD + mov gameplayTickBlocked, 1 + cmp isPlayerDead, 0 + jne update_player_done ; Freeze player movement and inputs during level transitions cmp fadeState, 0 jne update_player_done @@ -37,6 +51,7 @@ scene_update_tick: call UpdateCutscene ret normal_player_update: + mov gameplayTickBlocked, 0 mov moveX, 0 mov moveY, 0 @@ -62,6 +77,8 @@ check_action: call InteractDoor cmp fadeState, 0 jne update_player_done + cmp gameplayTickBlocked, 0 + jne update_player_done ; 2. Determine Movement Speed check_movement: diff --git a/include/engine_render2d.inc b/include/engine_render2d.inc index 02f880b..2eb83d4 100644 --- a/include/engine_render2d.inc +++ b/include/engine_render2d.inc @@ -90,9 +90,13 @@ DrawRect ENDP ; ------------------------------------------------------------------------------ ; DrawRectOutline: Draws a 1-pixel rectangle outline ; ------------------------------------------------------------------------------ -DrawRectOutline PROC x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD +DrawRectOutline PROC uses ebx, x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD LOCAL x2:DWORD, y2:DWORD + cmp w, 0 + jle outline_done + cmp h, 0 + jle outline_done mov eax, x add eax, w dec eax @@ -104,26 +108,28 @@ DrawRectOutline PROC x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD mov y2, eax ; Top & Bottom horizontal lines - mov ecx, x + ; DrawPixel may destroy EAX/ECX/EDX. Keep counters in callee-saved EBX. + mov ebx, x @@: - cmp ecx, x2 + cmp ebx, x2 jg @F - invoke DrawPixel, ecx, y, color - invoke DrawPixel, ecx, y2, color - inc ecx + invoke DrawPixel, ebx, y, color + invoke DrawPixel, ebx, y2, color + inc ebx jmp @B @@: ; Left & Right vertical lines - mov edx, y + mov ebx, y @@: - cmp edx, y2 + cmp ebx, y2 jg @F - invoke DrawPixel, x, edx, color - invoke DrawPixel, x2, edx, color - inc edx + invoke DrawPixel, x, ebx, color + invoke DrawPixel, x2, ebx, color + inc ebx jmp @B @@: +outline_done: ret DrawRectOutline ENDP diff --git a/main.obj b/main.obj index b268ec4..55c6cb0 100644 Binary files a/main.obj and b/main.obj differ diff --git a/tests/editor_regressions.cpp b/tests/editor_regressions.cpp index b024c18..43d6b84 100644 --- a/tests/editor_regressions.cpp +++ b/tests/editor_regressions.cpp @@ -23,6 +23,7 @@ static std::vector Bytes(const fs::path& path) { #include "cutscene_editor_checks.inl" #include "viewport_editor_checks.inl" +#include "stability_editor_checks.inl" int main(int argc, char** argv) { EditorApp app; @@ -152,6 +153,7 @@ int main(int argc, char** argv) { app.CleanupPlaytest(); } if(argc>2) RunViewportChecks(app,argv[2]); + RunStabilityEditorChecks(); std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n"; return failures ? 1 : 0; } diff --git a/tests/engine_regressions.asm b/tests/engine_regressions.asm index e530de5..672bb0f 100644 --- a/tests/engine_regressions.asm +++ b/tests/engine_regressions.asm @@ -126,6 +126,7 @@ state_changed: StateUnchanged ENDP include tests/cutscene_engine_checks.inc +include tests/stability_engine_checks.inc AuditMain PROC call InitMathTables @@ -363,6 +364,7 @@ flat_depth_ok: invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE call RunCutsceneChecks + call RunStabilityChecks mov eax, resultCount inc eax shl eax, 2 diff --git a/tests/stability_editor_checks.inl b/tests/stability_editor_checks.inl new file mode 100644 index 0000000..2c74c02 --- /dev/null +++ b/tests/stability_editor_checks.inl @@ -0,0 +1,66 @@ +static void RunStabilityEditorChecks() { + SpriteEntry sprite{}; + sprite.textureId = 13; + Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_GUARD, "zero flags retain legacy guard fallback"); + sprite.flags = 0x06026400; + Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_PROP, "explicit Prop matches ASM despite guard texture"); + for (int health : {1, 100, 255, 256, 300, 500}) { + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_GUARD, health, 2, 6); + Check(GetSpriteHealth(sprite) == (health > 255 ? 255 : health), "health setter clamps before byte packing"); + } + + const auto root = fs::absolute("code fixture"); + fs::create_directories(root / "include"); + const auto first = root / g_ProjectCodeFiles[0].relativePath; + const auto second = root / g_ProjectCodeFiles[1].relativePath; + { std::ofstream out(first); out << "original"; } + { std::ofstream out(second); out << "second"; } + CodeEditorState code; + code.LoadFile(0, root.string()); + Check(std::string(code.textBuffer.data()) == "original", "IDE opens actual source file"); + strcpy_s(code.textBuffer.data(), code.textBuffer.size(), "unsaved source"); + code.isModified = true; + HANDLE locked = CreateFileA(first.string().c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + Check(locked != INVALID_HANDLE_VALUE, "IDE failure fixture locks destination against replacement"); + Check(!code.SaveFile(root.string()) && code.isModified && + Bytes(first) == std::vector({'o','r','i','g','i','n','a','l'}), + "failed IDE replacement preserves disk and modified buffer"); + code.LoadFile(1, root.string()); + Check(code.currentFile == 0 && code.isModified && std::string(code.textBuffer.data()) == "unsaved source", + "IDE refuses file switch after failed autosave"); + code.StartBuild(root.string()); + Check(!code.isBuilding && code.isModified, "IDE refuses build after failed save"); + CloseHandle(locked); + Check(code.SaveFile(root.string()) && !code.isModified && + Bytes(first) == std::vector({'u','n','s','a','v','e','d',' ','s','o','u','r','c','e'}), + "IDE publishes complete source atomically"); + code.LoadFile(1, root.string()); + Check(code.currentFile == 1 && std::string(code.textBuffer.data()) == "second", "IDE switches after successful save"); + fs::remove(first); + code.LoadFile(0, root.string()); + Check(code.currentFile == 1 && std::string(code.textBuffer.data()) == "second", "failed IDE load preserves current document"); + Check(std::distance(fs::directory_iterator(root / "include"), fs::directory_iterator{}) == 1, + "failed IDE saves leave no temporary source files"); + + { std::ofstream out(root / "build.bat"); + out << "@echo off\nfor /L %%i in (1,1,4000) do echo compiler-output-%%i\nexit /b 7\n"; } + g_CodeEditor.LoadFile(1, root.string()); + g_CodeEditor.StartBuild(root.string()); + EditorApp hidden; + hidden.showCodeEditorModal = false; + const auto deadline = GetTickCount64() + 10000; + while (g_CodeEditor.isBuilding && GetTickCount64() < deadline) { + hidden.RenderCodeEditorWindow(); + if (g_CodeEditor.hBuildProcess) WaitForSingleObject(g_CodeEditor.hBuildProcess, 1); + } + const bool finished = !g_CodeEditor.isBuilding; + if (!finished && g_CodeEditor.hBuildProcess) { + TerminateProcess(g_CodeEditor.hBuildProcess, 99); + WaitForSingleObject(g_CodeEditor.hBuildProcess, 1000); + g_CodeEditor.PollBuild(); + } + Check(finished && g_CodeEditor.buildExitCode == 7 && + g_CodeEditor.buildLog.find("compiler-output-4000") != std::string::npos, + "hidden IDE drains output larger than pipe capacity and retains exit code"); +} diff --git a/tests/stability_engine_checks.inc b/tests/stability_engine_checks.inc new file mode 100644 index 0000000..1ee76f6 --- /dev/null +++ b/tests/stability_engine_checks.inc @@ -0,0 +1,202 @@ +; Real gameplay procedures, deliberately without Render3DScene between shots. +.code +ResetCombatFixture PROC uses eax ecx edi + mov edi, OFFSET gameMap + mov ecx, MAP_SIZE + xor eax, eax + rep stosb + mov fadeState, 0 + mov isCutsceneActive, 0 + mov cutscenePending, 0 + mov isPlayerDead, 0 + mov playerHealth, 100 + mov playerAmmo, 50 + mov playerScore, 0 + mov playerAttackCooldown, 0 + mov playerFlashTimer, 0 + mov playerHitFlash, 0 + mov playerX, 160 + mov playerY, 160 + mov playerAngle, 0 + mov keyForward, 0 + mov keyBackward, 0 + mov keyStrafeLeft, 0 + mov keyStrafeRight, 0 + mov keyTurnLeft, 0 + mov keyTurnRight, 0 + mov keyCutscene, 0 + mov keySkipScene, 0 + mov keyAction, 0 + mov keyRestart, 0 + mov keyAttack, 1 + mov transitionCount, 0 + mov spriteCount, 1 + mov [spriteList].SpriteStruct.x, 288 + mov [spriteList].SpriteStruct.y, 160 + mov [spriteList].SpriteStruct.textureId, 13 + mov [spriteList].SpriteStruct.scale, FIXED_ONE + mov [spriteList].SpriteStruct.flags, 0 + call InitEntities + call UpdatePlayer + ret +ResetCombatFixture ENDP + +RunStabilityChecks PROC + mov edi, OFFSET screenBuffer + mov ecx, FRAME_BUFFER_SIZE/4 + mov eax, 00123456h + rep stosd + invoke DrawRectOutline, 68, 42, 60, 8, COLOR_WHITE + CHECK DWORD PTR [screenBuffer+42*SCREEN_PITCH+127*4], COLOR_WHITE ; white outline reaches top right + CHECK DWORD PTR [screenBuffer+49*SCREEN_PITCH+100*4], COLOR_WHITE ; white outline draws bottom edge + CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+68*4], COLOR_WHITE ; white outline draws left edge + CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+100*4], 00123456h ; outline keeps interior unchanged + invoke DrawRectOutline, 68, 42, 60, 8, 0 + CHECK DWORD PTR [screenBuffer+49*SCREEN_PITCH+100*4], 0 ; black outline terminates and draws bottom + invoke DrawRectOutline, -2, -2, 5, 5, COLOR_GREEN + CHECK DWORD PTR [screenBuffer+2*SCREEN_PITCH], COLOR_GREEN ; outline clips offscreen coordinates + invoke DrawRectOutline, 100, 45, 0, 3, COLOR_WHITE + CHECK DWORD PTR [screenBuffer+45*SCREEN_PITCH+100*4], 00123456h ; zero width outline draws nothing + + call ResetCombatFixture + mov DWORD PTR [depthBuffer+160*4], 0 + call UpdateCombat + CHECK entityHealth, 65 ; hitscan works without a rendered depth buffer + CHECK playerAmmo, 49 ; one shot consumes one round + mov playerAttackCooldown, 0 + mov byte ptr [gameMap+2*MAP_W+3], CELL_BRICK + mov DWORD PTR [depthBuffer+160*4], 999999 + call UpdateCombat + CHECK entityHealth, 65 ; newly placed wall blocks a shot despite stale depth + mov playerAttackCooldown, 0 + mov byte ptr [gameMap+2*MAP_W+3], CELL_DOOR_OPEN + call UpdateCombat + CHECK entityHealth, 30 ; newly opened door permits a shot without rendering + mov playerAttackCooldown, 0 + mov byte ptr [gameMap+2*MAP_W+3], CELL_GLASS + call UpdateCombat + CHECK entityHealth, 30 ; solid glass blocks gameplay shots + + call ResetCombatFixture + mov [spriteList].SpriteStruct.x, 800 + mov [spriteList].SpriteStruct.y, 180 + mov DWORD PTR [depthBuffer+160*4], 999999 + call UpdateCombat + CHECK entityHealth, 100 ; distant target does not acquire a larger world hitbox + call ResetCombatFixture + mov [spriteList].SpriteStruct.x, 160 + mov [spriteList].SpriteStruct.y, 288 + mov playerAngle, ANGLE_90 + mov DWORD PTR [depthBuffer+160*4], 0 + call UpdateCombat + CHECK entityHealth, 65 ; cardinal turn uses current camera instead of previous frame + call ResetCombatFixture + mov playerAngle, ANGLE_180 + mov [spriteList].SpriteStruct.x, 32 + call UpdateCombat + CHECK entityHealth, 65 ; negative cardinal shot traverses the current map + call ResetCombatFixture + mov playerAngle, ANGLE_45 + mov [spriteList].SpriteStruct.y, 288 + call UpdateCombat + CHECK entityHealth, 65 ; diagonal shot reaches unobstructed target + mov playerAttackCooldown, 0 + mov byte ptr [gameMap+2*MAP_W+3], CELL_BRICK + call UpdateCombat + CHECK entityHealth, 65 ; diagonal ray cannot pass through a wall corner + call ResetCombatFixture + mov spriteCount, 2 + mov esi, OFFSET spriteList+sizeof SpriteStruct + mov [esi].SpriteStruct.x, 224 + mov [esi].SpriteStruct.y, 160 + mov [esi].SpriteStruct.textureId, 13 + mov [esi].SpriteStruct.scale, FIXED_ONE + mov [esi].SpriteStruct.flags, 0 + call InitEntities + call UpdateCombat + CHECK entityHealth, 100 ; hitscan does not damage farther overlapping guard + CHECK entityHealth[4], 65 ; hitscan selects nearest live guard independent of insertion order + + call ResetCombatFixture + mov [spriteList].SpriteStruct.y, 180 + mov [spriteList].SpriteStruct.scale, 2*FIXED_ONE + call UpdateCombat + CHECK entityHealth, 65 ; authored sprite scale affects world-space hitbox + mov playerAttackCooldown, 0 + mov [spriteList].SpriteStruct.scale, 0 + call UpdateCombat + CHECK entityHealth, 65 ; zero sprite scale retains legacy one-unit scale + + call ResetCombatFixture + mov isCutsceneActive, 1 + mov playerAttackCooldown, 4 + call UpdateEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.x, 288 ; cinematic camera cannot attract guards + CHECK playerAmmo, 50 ; cinematic camera cannot fire + CHECK playerAttackCooldown, 4 ; cinematic pauses weapon timers + mov isCutsceneActive, 0 + mov fadeState, 1 + call UpdateEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.x, 288 ; transition freezes guards + CHECK playerAmmo, 50 ; transition blocks firing + mov fadeState, 0 + mov cutscenePending, 1 + call UpdateEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.x, 288 ; pending autoplay blocks gameplay before first scene tick + CHECK playerAmmo, 50 ; pending autoplay blocks firing + + call ResetCombatFixture + mov isCutsceneActive, 1 + mov keySkipScene, 1 + call UpdatePlayer + call UpdateEntities + call UpdateCombat + CHECK isCutsceneActive, 0 ; skip stops cinematic playback + CHECK [spriteList].SpriteStruct.x, 288 ; skip tick does not resume guards midway through tick + CHECK playerAmmo, 50 ; skip tick cannot fire stale scene input + call ResetCombatFixture + invoke SwitchSublevel, OFFSET arenaPath, 2, 2, 0, 0 + call UpdateCombat + CHECK playerAmmo, 50 ; instant transition cannot fire into newly loaded room + + call ResetCombatFixture + mov [spriteList].SpriteStruct.x, 160 + mov [spriteList].SpriteStruct.textureId, 14 + call InitEntities + mov playerHealth, 50 + mov isCutsceneActive, 1 + call UpdateEntities + CHECK playerHealth, 50 ; cinematic camera cannot collect a medkit + CHECK [spriteList].SpriteStruct.textureId, 14 ; cinematic leaves pickup active + mov isCutsceneActive, 0 + call UpdateEntities + CHECK playerHealth, 75 ; normal gameplay collects medkit + + call ResetCombatFixture + mov isPlayerDead, 1 + mov keyForward, 1 + mov keyTurnRight, 1 + call UpdatePlayer + call UpdateCombat + CHECK playerX, 160 ; death blocks movement + CHECK playerAngle, 0 ; death blocks turning + CHECK playerAmmo, 50 ; death blocks firing + mov isPlayerDead, 0 + mov keyForward, 0 + mov keyTurnRight, 0 + call UpdatePlayer + + mov [spriteList].SpriteStruct.flags, 0602FF01h + call InitEntities + CHECK entityHealth, 255 ; packed guard health preserves maximum byte + mov [spriteList].SpriteStruct.flags, 06026400h + call InitEntities + CHECK entityType, ENTITY_TYPE_PROP ; nonzero packed Prop never falls back to texture + mov [spriteList].SpriteStruct.flags, 0 + call InitEntities + CHECK entityType, ENTITY_TYPE_GUARD ; zero flags keep legacy texture fallback + ret +RunStabilityChecks ENDP diff --git a/tools/test_regressions.py b/tools/test_regressions.py index fb1e06e..9ea3682 100644 --- a/tools/test_regressions.py +++ b/tools/test_regressions.py @@ -133,7 +133,7 @@ def main(): (work / "project_paths.inc").write_text( f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n' f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\n') - masm_candidates = [Path(p) for p in ("D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")] + masm_candidates = [Path(p) for p in ("E:/MASM32/masm32/bin/ml.exe", "D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")] masm_candidates += list(Path("C:/Program Files (x86)/Microsoft Visual Studio").glob("*/BuildTools/VC/Tools/MSVC/*/bin/Hostx64/x86/ml.exe")) ml = next((p for p in masm_candidates if p.is_file()), None) if not ml: @@ -148,7 +148,9 @@ def main(): f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"), "kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work) run([str(work / "engine_tests.exe"), "pace map.dat"], work) - source = (ROOT / "tests/engine_regressions.asm").read_text() + (ROOT / "tests/cutscene_engine_checks.inc").read_text() + source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join( + (ROOT / "tests" / name).read_text() for name in + ("cutscene_engine_checks.inc", "stability_engine_checks.inc")) names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M) raw = (work / "engine_results.bin").read_bytes() values = struct.unpack(f"<{len(raw)//4}I", raw)