fix: stabilize gameplay and IDE source writes
Trace shots against current map geometry instead of stale render depth. Preserve outline counters across volatile pixel calls and freeze gameplay on cinematic, transition and death ticks. Clamp packed HP before narrowing and match legacy archetype rules. Publish IDE saves atomically; failed saves block file switches and builds. Drain compiler output while hidden. Verify runtime/editor builds, 112 editor checks, 148 ASM checks and live preview parity. Record remaining stages in docs/STABILIZATION.md.
This commit is contained in:
parent
c1da810bd5
commit
f42679fe62
18 changed files with 594 additions and 70 deletions
67
docs/STABILIZATION.md
Normal file
67
docs/STABILIZATION.md
Normal file
|
|
@ -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.
|
||||||
Binary file not shown.
|
|
@ -2102,18 +2102,18 @@ void EditorApp::RenderInspector() {
|
||||||
|
|
||||||
if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) {
|
if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) {
|
||||||
PushUndo();
|
PushUndo();
|
||||||
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archType == ENTITY_ARCHETYPE_GUARD) {
|
if (archType == ENTITY_ARCHETYPE_GUARD) {
|
||||||
if (ImGui::SliderInt("Здоровье (HP)", &hp, 10, 500)) {
|
if (ImGui::SliderInt("Здоровье (HP)", &hp, 1, 255)) {
|
||||||
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
||||||
}
|
}
|
||||||
if (ImGui::SliderInt("Скорость", &spd, 1, 8)) {
|
if (ImGui::SliderInt("Скорость", &spd, 1, 8)) {
|
||||||
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
||||||
}
|
}
|
||||||
if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) {
|
if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) {
|
||||||
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), static_cast<uint8_t>(hp), static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
SetSpriteEntityData(spr, static_cast<uint8_t>(archType), hp, static_cast<uint8_t>(spd), static_cast<uint8_t>(agg));
|
||||||
}
|
}
|
||||||
} else if (archType == ENTITY_ARCHETYPE_MEDKIT) {
|
} else if (archType == ENTITY_ARCHETYPE_MEDKIT) {
|
||||||
ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе");
|
ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе");
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ static const int g_ScriptTemplateCount = sizeof(g_ScriptTemplates) / sizeof(g_Sc
|
||||||
struct CodeEditorState {
|
struct CodeEditorState {
|
||||||
int currentFile = 0;
|
int currentFile = 0;
|
||||||
std::string fileContent;
|
std::string fileContent;
|
||||||
|
fs::path loadedPath;
|
||||||
std::vector<char> textBuffer;
|
std::vector<char> textBuffer;
|
||||||
bool isModified = false;
|
bool isModified = false;
|
||||||
std::string statusText = "Готов к редактированию кода MASM32";
|
std::string statusText = "Готов к редактированию кода MASM32";
|
||||||
|
|
@ -120,50 +121,64 @@ struct CodeEditorState {
|
||||||
|
|
||||||
void LoadFile(int fileIndex, const std::string& projectRoot) {
|
void LoadFile(int fileIndex, const std::string& projectRoot) {
|
||||||
if (fileIndex < 0 || fileIndex >= g_CodeFileCount) return;
|
if (fileIndex < 0 || fileIndex >= g_CodeFileCount) return;
|
||||||
currentFile = fileIndex;
|
if (isModified && !SaveFile(projectRoot)) return;
|
||||||
fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[fileIndex].relativePath;
|
fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[fileIndex].relativePath;
|
||||||
if (!fs::exists(fullPath)) {
|
if (!fs::exists(fullPath)) {
|
||||||
fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[fileIndex].relativePath;
|
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);
|
HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
if (hFile != INVALID_HANDLE_VALUE) {
|
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;
|
|
||||||
statusText = "Не удалось открыть файл: " + fullPath.string();
|
statusText = "Не удалось открыть файл: " + fullPath.string();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
LARGE_INTEGER size{};
|
||||||
|
bool ok = GetFileSizeEx(hFile, &size) && size.QuadPart >= 0 && size.QuadPart <= 16 * 1024 * 1024;
|
||||||
|
std::vector<char> incoming(ok ? static_cast<size_t>(size.QuadPart) + 1 : 1, 0);
|
||||||
|
DWORD read = 0;
|
||||||
|
if (ok) ok = ReadFile(hFile, incoming.data(), static_cast<DWORD>(size.QuadPart), &read, nullptr) &&
|
||||||
|
read == static_cast<DWORD>(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) {
|
bool SaveFile(const std::string& projectRoot) {
|
||||||
if (currentFile < 0 || currentFile >= g_CodeFileCount) return false;
|
if (currentFile < 0 || currentFile >= g_CodeFileCount || loadedPath.empty() || textBuffer.empty()) {
|
||||||
fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[currentFile].relativePath;
|
statusText = "Сначала откройте исходный файл.";
|
||||||
if (!fs::exists(fullPath)) {
|
return false;
|
||||||
fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[currentFile].relativePath;
|
|
||||||
}
|
}
|
||||||
|
const auto fullPath = loadedPath;
|
||||||
HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
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) {
|
if (hFile == INVALID_HANDLE_VALUE) {
|
||||||
statusText = "Ошибка сохранения: доступ запрещен (" + fullPath.string() + ")";
|
statusText = "Ошибка сохранения: доступ запрещен (" + fullPath.string() + ")";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
DWORD len = static_cast<DWORD>(std::strlen(textBuffer.data()));
|
DWORD len = static_cast<DWORD>(std::strlen(textBuffer.data()));
|
||||||
DWORD written = 0;
|
DWORD written = 0;
|
||||||
WriteFile(hFile, textBuffer.data(), len, &written, nullptr);
|
bool ok = WriteFile(hFile, textBuffer.data(), len, &written, nullptr) && written == len;
|
||||||
CloseHandle(hFile);
|
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;
|
isModified = false;
|
||||||
statusText = "Файл сохранен: " + g_ProjectCodeFiles[currentFile].displayName;
|
statusText = "Файл сохранен: " + g_ProjectCodeFiles[currentFile].displayName;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -173,7 +188,7 @@ struct CodeEditorState {
|
||||||
if (isBuilding) return;
|
if (isBuilding) return;
|
||||||
|
|
||||||
// Auto-save current file before building
|
// Auto-save current file before building
|
||||||
SaveFile(projectRoot);
|
if (!SaveFile(projectRoot)) return;
|
||||||
|
|
||||||
buildLog.clear();
|
buildLog.clear();
|
||||||
buildErrors.clear();
|
buildErrors.clear();
|
||||||
|
|
@ -214,9 +229,12 @@ struct CodeEditorState {
|
||||||
si.wShowWindow = SW_HIDE;
|
si.wShowWindow = SW_HIDE;
|
||||||
|
|
||||||
PROCESS_INFORMATION pi{};
|
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);
|
CloseHandle(pi.hThread);
|
||||||
hBuildProcess = pi.hProcess;
|
hBuildProcess = pi.hProcess;
|
||||||
isBuilding = true;
|
isBuilding = true;
|
||||||
|
|
@ -225,6 +243,8 @@ struct CodeEditorState {
|
||||||
buildLog = "[ERROR] Не удалось запустить процесс сборки.\n";
|
buildLog = "[ERROR] Не удалось запустить процесс сборки.\n";
|
||||||
hasBuildResult = true;
|
hasBuildResult = true;
|
||||||
buildExitCode = 1;
|
buildExitCode = 1;
|
||||||
|
CloseHandle(hReadPipe);
|
||||||
|
hReadPipe = nullptr;
|
||||||
}
|
}
|
||||||
CloseHandle(hWritePipe);
|
CloseHandle(hWritePipe);
|
||||||
}
|
}
|
||||||
|
|
@ -316,9 +336,9 @@ struct CodeEditorState {
|
||||||
static CodeEditorState g_CodeEditor;
|
static CodeEditorState g_CodeEditor;
|
||||||
|
|
||||||
void EditorApp::RenderCodeEditorWindow() {
|
void EditorApp::RenderCodeEditorWindow() {
|
||||||
if (!showCodeEditorModal) return;
|
// Drain compiler output even when the IDE is hidden; a full pipe blocks ml/link.
|
||||||
|
|
||||||
g_CodeEditor.PollBuild();
|
g_CodeEditor.PollBuild();
|
||||||
|
if (!showCodeEditorModal) return;
|
||||||
|
|
||||||
ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver);
|
||||||
if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) {
|
if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) {
|
||||||
|
|
@ -408,9 +428,6 @@ void EditorApp::RenderCodeEditorWindow() {
|
||||||
for (int i = 0; i < g_CodeFileCount; ++i) {
|
for (int i = 0; i < g_CodeFileCount; ++i) {
|
||||||
bool isSelected = (g_CodeEditor.currentFile == i);
|
bool isSelected = (g_CodeEditor.currentFile == i);
|
||||||
if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) {
|
if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) {
|
||||||
if (g_CodeEditor.isModified) {
|
|
||||||
g_CodeEditor.SaveFile(root);
|
|
||||||
}
|
|
||||||
g_CodeEditor.LoadFile(i, root);
|
g_CodeEditor.LoadFile(i, root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -512,7 +529,6 @@ void EditorApp::RenderCodeEditorWindow() {
|
||||||
for (int f = 0; f < g_CodeFileCount; ++f) {
|
for (int f = 0; f < g_CodeFileCount; ++f) {
|
||||||
if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos ||
|
if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos ||
|
||||||
g_ProjectCodeFiles[f].relativePath.find(err.file) != 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);
|
g_CodeEditor.LoadFile(f, root);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ struct SpriteEntry {
|
||||||
|
|
||||||
inline uint8_t GetSpriteEntityType(const SpriteEntry& s) {
|
inline uint8_t GetSpriteEntityType(const SpriteEntry& s) {
|
||||||
uint8_t t = static_cast<uint8_t>(s.flags & 0xFF);
|
uint8_t t = static_cast<uint8_t>(s.flags & 0xFF);
|
||||||
if (t == 0) {
|
if (s.flags == 0) {
|
||||||
if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD;
|
if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD;
|
||||||
if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT;
|
if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT;
|
||||||
if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO;
|
if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO;
|
||||||
|
|
@ -122,7 +122,10 @@ inline uint8_t GetSpriteAggro(const SpriteEntry& s) {
|
||||||
uint8_t agg = static_cast<uint8_t>((s.flags >> 24) & 0xFF);
|
uint8_t agg = static_cast<uint8_t>((s.flags >> 24) & 0xFF);
|
||||||
return (agg == 0) ? 6 : agg;
|
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<uint32_t>(type) |
|
s.flags = static_cast<uint32_t>(type) |
|
||||||
(static_cast<uint32_t>(health) << 8) |
|
(static_cast<uint32_t>(health) << 8) |
|
||||||
(static_cast<uint32_t>(speed) << 16) |
|
(static_cast<uint32_t>(speed) << 16) |
|
||||||
|
|
|
||||||
BIN
engine.exe
BIN
engine.exe
Binary file not shown.
|
|
@ -10,6 +10,130 @@ DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||||
DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD
|
DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD
|
||||||
DrawNumber8x8 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
|
; 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 i:DWORD, closestIdx:DWORD, closestDist:DWORD
|
||||||
LOCAL sprX:DWORD, sprY:DWORD
|
LOCAL sprX:DWORD, sprY:DWORD
|
||||||
LOCAL transX:DWORD, transY: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
|
; 1. Decrement cooldowns
|
||||||
cmp playerAttackCooldown, 0
|
cmp playerAttackCooldown, 0
|
||||||
|
|
@ -63,8 +191,8 @@ UpdateCombat PROC uses ebx ecx edx esi edi
|
||||||
mov edx, [tableSin + eax*4]
|
mov edx, [tableSin + eax*4]
|
||||||
mov camSin, edx
|
mov camSin, edx
|
||||||
|
|
||||||
; Center wall depth from depthBuffer[160]
|
; Query current geometry, never the previous rendered frame.
|
||||||
mov eax, [depthBuffer + SCREEN_HALF_W * 4]
|
call TraceShotWall
|
||||||
mov wallDepth, eax
|
mov wallDepth, eax
|
||||||
|
|
||||||
mov closestIdx, -1
|
mov closestIdx, -1
|
||||||
|
|
@ -117,8 +245,9 @@ hitscan_loop:
|
||||||
cmp transY, 16
|
cmp transY, 16
|
||||||
jle next_hitscan_target
|
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
|
mov eax, transY
|
||||||
|
shl eax, 8
|
||||||
cmp eax, wallDepth
|
cmp eax, wallDepth
|
||||||
jge next_hitscan_target
|
jge next_hitscan_target
|
||||||
|
|
||||||
|
|
@ -133,20 +262,21 @@ hitscan_loop:
|
||||||
add ecx, eax
|
add ecx, eax
|
||||||
mov transX, ecx
|
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
|
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
|
cdq
|
||||||
xor eax, edx
|
xor eax, edx
|
||||||
sub eax, edx ; abs(sprScreenX - 160)
|
sub eax, edx
|
||||||
cmp eax, 22
|
cmp eax, targetHalfWidth
|
||||||
jg next_hitscan_target
|
jg next_hitscan_target
|
||||||
|
|
||||||
; Check if closer than previous target
|
; Check if closer than previous target
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ hEngineInstance dd 0
|
||||||
isRunning dd 1
|
isRunning dd 1
|
||||||
|
|
||||||
; High Precision Timing
|
; 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)
|
targetFrameTimeMs dd 16 ; ~60 FPS (16.6 ms)
|
||||||
lastTickCount dd 0
|
lastTickCount dd 0
|
||||||
fpsCounter dd 0
|
fpsCounter dd 0
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,9 @@ UpdateEntities PROC uses ebx ecx edx esi edi
|
||||||
ret
|
ret
|
||||||
|
|
||||||
entities_active:
|
entities_active:
|
||||||
|
call IsGameplayBlocked
|
||||||
|
test eax, eax
|
||||||
|
jnz entities_done
|
||||||
mov i, 0
|
mov i, 0
|
||||||
|
|
||||||
entity_loop:
|
entity_loop:
|
||||||
|
|
@ -376,6 +379,8 @@ check_guard_attack:
|
||||||
test eax, eax
|
test eax, eax
|
||||||
jnz next_entity
|
jnz next_entity
|
||||||
mov isPlayerDead, 1
|
mov isPlayerDead, 1
|
||||||
|
; Nothing else may collect items or attack after a lethal hit this tick.
|
||||||
|
jmp entities_done
|
||||||
|
|
||||||
next_entity:
|
next_entity:
|
||||||
inc i
|
inc i
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,9 @@ SwitchSublevel PROC uses esi edi, pszNextMap:DWORD, spawnX:DWORD, spawnY:DWORD,
|
||||||
cmp fadeState, 0
|
cmp fadeState, 0
|
||||||
jne switch_busy
|
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
|
mov esi, pszNextMap
|
||||||
lea edi, pendingMapPath
|
lea edi, pendingMapPath
|
||||||
mov ecx, 32
|
mov ecx, 32
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@
|
||||||
|
|
||||||
.code
|
.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
|
; 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 nextX:DWORD, nextY:DWORD
|
||||||
LOCAL checkGridX:DWORD, checkGridY:DWORD
|
LOCAL checkGridX:DWORD, checkGridY:DWORD
|
||||||
|
|
||||||
|
mov gameplayTickBlocked, 1
|
||||||
|
cmp isPlayerDead, 0
|
||||||
|
jne update_player_done
|
||||||
; Freeze player movement and inputs during level transitions
|
; Freeze player movement and inputs during level transitions
|
||||||
cmp fadeState, 0
|
cmp fadeState, 0
|
||||||
jne update_player_done
|
jne update_player_done
|
||||||
|
|
@ -37,6 +51,7 @@ scene_update_tick:
|
||||||
call UpdateCutscene
|
call UpdateCutscene
|
||||||
ret
|
ret
|
||||||
normal_player_update:
|
normal_player_update:
|
||||||
|
mov gameplayTickBlocked, 0
|
||||||
mov moveX, 0
|
mov moveX, 0
|
||||||
mov moveY, 0
|
mov moveY, 0
|
||||||
|
|
||||||
|
|
@ -62,6 +77,8 @@ check_action:
|
||||||
call InteractDoor
|
call InteractDoor
|
||||||
cmp fadeState, 0
|
cmp fadeState, 0
|
||||||
jne update_player_done
|
jne update_player_done
|
||||||
|
cmp gameplayTickBlocked, 0
|
||||||
|
jne update_player_done
|
||||||
|
|
||||||
; 2. Determine Movement Speed
|
; 2. Determine Movement Speed
|
||||||
check_movement:
|
check_movement:
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,13 @@ DrawRect ENDP
|
||||||
; ------------------------------------------------------------------------------
|
; ------------------------------------------------------------------------------
|
||||||
; DrawRectOutline: Draws a 1-pixel rectangle outline
|
; 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
|
LOCAL x2:DWORD, y2:DWORD
|
||||||
|
|
||||||
|
cmp w, 0
|
||||||
|
jle outline_done
|
||||||
|
cmp h, 0
|
||||||
|
jle outline_done
|
||||||
mov eax, x
|
mov eax, x
|
||||||
add eax, w
|
add eax, w
|
||||||
dec eax
|
dec eax
|
||||||
|
|
@ -104,26 +108,28 @@ DrawRectOutline PROC x:DWORD, y:DWORD, w:DWORD, h:DWORD, color:DWORD
|
||||||
mov y2, eax
|
mov y2, eax
|
||||||
|
|
||||||
; Top & Bottom horizontal lines
|
; 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
|
jg @F
|
||||||
invoke DrawPixel, ecx, y, color
|
invoke DrawPixel, ebx, y, color
|
||||||
invoke DrawPixel, ecx, y2, color
|
invoke DrawPixel, ebx, y2, color
|
||||||
inc ecx
|
inc ebx
|
||||||
jmp @B
|
jmp @B
|
||||||
@@:
|
@@:
|
||||||
|
|
||||||
; Left & Right vertical lines
|
; Left & Right vertical lines
|
||||||
mov edx, y
|
mov ebx, y
|
||||||
@@:
|
@@:
|
||||||
cmp edx, y2
|
cmp ebx, y2
|
||||||
jg @F
|
jg @F
|
||||||
invoke DrawPixel, x, edx, color
|
invoke DrawPixel, x, ebx, color
|
||||||
invoke DrawPixel, x2, edx, color
|
invoke DrawPixel, x2, ebx, color
|
||||||
inc edx
|
inc ebx
|
||||||
jmp @B
|
jmp @B
|
||||||
@@:
|
@@:
|
||||||
|
outline_done:
|
||||||
ret
|
ret
|
||||||
DrawRectOutline ENDP
|
DrawRectOutline ENDP
|
||||||
|
|
||||||
|
|
|
||||||
BIN
main.obj
BIN
main.obj
Binary file not shown.
|
|
@ -23,6 +23,7 @@ static std::vector<uint8_t> Bytes(const fs::path& path) {
|
||||||
|
|
||||||
#include "cutscene_editor_checks.inl"
|
#include "cutscene_editor_checks.inl"
|
||||||
#include "viewport_editor_checks.inl"
|
#include "viewport_editor_checks.inl"
|
||||||
|
#include "stability_editor_checks.inl"
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
EditorApp app;
|
EditorApp app;
|
||||||
|
|
@ -152,6 +153,7 @@ int main(int argc, char** argv) {
|
||||||
app.CleanupPlaytest();
|
app.CleanupPlaytest();
|
||||||
}
|
}
|
||||||
if(argc>2) RunViewportChecks(app,argv[2]);
|
if(argc>2) RunViewportChecks(app,argv[2]);
|
||||||
|
RunStabilityEditorChecks();
|
||||||
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
|
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
|
||||||
return failures ? 1 : 0;
|
return failures ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ state_changed:
|
||||||
StateUnchanged ENDP
|
StateUnchanged ENDP
|
||||||
|
|
||||||
include tests/cutscene_engine_checks.inc
|
include tests/cutscene_engine_checks.inc
|
||||||
|
include tests/stability_engine_checks.inc
|
||||||
|
|
||||||
AuditMain PROC
|
AuditMain PROC
|
||||||
call InitMathTables
|
call InitMathTables
|
||||||
|
|
@ -363,6 +364,7 @@ flat_depth_ok:
|
||||||
invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
|
invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
|
||||||
|
|
||||||
call RunCutsceneChecks
|
call RunCutsceneChecks
|
||||||
|
call RunStabilityChecks
|
||||||
mov eax, resultCount
|
mov eax, resultCount
|
||||||
inc eax
|
inc eax
|
||||||
shl eax, 2
|
shl eax, 2
|
||||||
|
|
|
||||||
66
tests/stability_editor_checks.inl
Normal file
66
tests/stability_editor_checks.inl
Normal file
|
|
@ -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<uint8_t>({'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<uint8_t>({'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");
|
||||||
|
}
|
||||||
202
tests/stability_engine_checks.inc
Normal file
202
tests/stability_engine_checks.inc
Normal file
|
|
@ -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
|
||||||
|
|
@ -133,7 +133,7 @@ def main():
|
||||||
(work / "project_paths.inc").write_text(
|
(work / "project_paths.inc").write_text(
|
||||||
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
|
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
|
||||||
f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\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"))
|
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)
|
ml = next((p for p in masm_candidates if p.is_file()), None)
|
||||||
if not ml:
|
if not ml:
|
||||||
|
|
@ -148,7 +148,9 @@ def main():
|
||||||
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
|
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
|
||||||
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
|
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
|
||||||
run([str(work / "engine_tests.exe"), "pace map.dat"], 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)
|
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
|
||||||
raw = (work / "engine_results.bin").read_bytes()
|
raw = (work / "engine_results.bin").read_bytes()
|
||||||
values = struct.unpack(f"<{len(raw)//4}I", raw)
|
values = struct.unpack(f"<{len(raw)//4}I", raw)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue