From b83cde8141211fdd57050c4ab9bfb1879c1f2ab1 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Tue, 27 Oct 2026 22:27:50 -0400 Subject: [PATCH] Write the static ID manifest and check DB references at boot nextStaticID now takes the object type and a natural manifest key, so no static ID is handed out without a manifest entry. After the world load the server diffs maps/.manifest against the previous boot (moved, vanished, re-targeted IDs), writes the new one (old copy kept as .manifest.prev), and logs every negative reference in the Task 2.1 columns that no loaded static object of the right type holds. Value changes: none in play. Guilds are the only static IDs so far (-2..-25, keys guild:). Co-Authored-By: Claude Opus 5.5 --- .../db/handlers/dbStaticWorldHandler.java | 47 ++++ src/engine/gameManager/DbManager.java | 6 +- src/engine/gameManager/StaticManifest.java | 246 ++++++++++++++++++ src/engine/objects/Guild.java | 3 +- src/engine/server/world/WorldServer.java | 3 + 5 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/engine/db/handlers/dbStaticWorldHandler.java create mode 100644 src/engine/gameManager/StaticManifest.java diff --git a/src/engine/db/handlers/dbStaticWorldHandler.java b/src/engine/db/handlers/dbStaticWorldHandler.java new file mode 100644 index 00000000..3a045992 --- /dev/null +++ b/src/engine/db/handlers/dbStaticWorldHandler.java @@ -0,0 +1,47 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.db.handlers; + +import engine.gameManager.DbManager; +import org.pmw.tinylog.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; + +public class dbStaticWorldHandler extends dbHandlerBase { + + public dbStaticWorldHandler() { + + } + + // Table and column come from StaticManifest's fixed list, never from input. -1 means "none" and is skipped. + public HashMap GET_NEGATIVE_REFERENCES(String table, String column) { + + HashMap rowsByValue = new HashMap<>(); + + try (Connection connection = DbManager.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement("SELECT `" + column + "` AS `value`, COUNT(*) AS `rows` FROM `" + + table + "` WHERE `" + column + "` < -1 GROUP BY `" + column + "`;")) { + + ResultSet rs = preparedStatement.executeQuery(); + + while (rs.next()) + rowsByValue.put(rs.getInt("value"), rs.getInt("rows")); + + } catch (SQLException e) { + Logger.error(table + "." + column + ": " + e); + return null; + } + + return rowsByValue; + } +} diff --git a/src/engine/gameManager/DbManager.java b/src/engine/gameManager/DbManager.java index 2efece7f..35012009 100644 --- a/src/engine/gameManager/DbManager.java +++ b/src/engine/gameManager/DbManager.java @@ -69,6 +69,7 @@ public enum DbManager { public static final dbBlueprintHandler BlueprintQueries = new dbBlueprintHandler(); public static final dbShrineHandler ShrineQueries = new dbShrineHandler(); public static final dbRunegateHandler RunegateQueries = new dbRunegateHandler(); + public static final dbStaticWorldHandler StaticWorldQueries = new dbStaticWorldHandler(); public static final dbPetitionHandler PetitionQueries = new dbPetitionHandler(); private static final EnumMap> objectCache = new EnumMap<>(GameObjectType.class); @@ -76,13 +77,16 @@ public enum DbManager { private static HikariDataSource connectionPool = null; // Only the static world factories may call this, in the fixed load order (W2); never renumber. - public static int nextStaticID() { + // The key names the object from the data files alone (findings §5, manifest format) and must be unique. + public static int nextStaticID(GameObjectType objectType, String manifestKey) { int staticID = lastStaticID.decrementAndGet(); if (staticID <= RUNTIME_ID_START) throw new IllegalStateException("Static world ID " + staticID + " reached the runtime ID range at " + RUNTIME_ID_START); + StaticManifest.addEntry(staticID, objectType, manifestKey); + return staticID; } diff --git a/src/engine/gameManager/StaticManifest.java b/src/engine/gameManager/StaticManifest.java new file mode 100644 index 00000000..a94f6f6c --- /dev/null +++ b/src/engine/gameManager/StaticManifest.java @@ -0,0 +1,246 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.gameManager; + +import engine.mbEnums.GameObjectType; +import engine.parsers.cache.CacheParserUtil; +import org.json.JSONArray; +import org.json.JSONObject; +import org.pmw.tinylog.Logger; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +// W2 safety net: every static ID with its natural key, in load order, written to maps/.manifest at boot. +// Diffing against the previous boot's file shows IDs that moved; the orphan check shows DB references they broke. + +public enum StaticManifest { + + STATICMANIFEST; + + private static final String mapPath = ConfigManager.DEFAULT_DATA_DIR + "maps/"; + + // Player data columns that can hold a static ID (findings §5, Task 2.1). A null type accepts any static object. + private static final String[][] referenceColumns = { + {"object", "parent", null}, + {"obj_character", "char_bindBuilding", "Building"}, + {"obj_character", "guildUID", "Guild"}, + {"dyn_character_guildhistory", "GuildID", "Guild"}, + {"dyn_guild_allianceenemylist", "OtherGuildID", "Guild"}, + {"dyn_guild_guildkoslist", "KOSGuildID", "Guild"}, + {"obj_mob", "mob_buildingID", "Building"}, + {"obj_mob", "mob_guildUID", "Guild"}, + {"obj_npc", "npc_buildingID", "Building"}, + {"obj_npc", "npc_guildID", "Guild"}, + {"obj_city", "treeOfLifeUUID", "Building"}, + {"static_runegate_portals", "sourceBuilding", "Building"}, + {"static_runegate_portals", "targetBuilding", "Building"}, + {"obj_mine", "mine_buildingUID", "Building"} + }; + + private static final ArrayList entries = new ArrayList<>(); + private static final HashMap entriesByID = new HashMap<>(); + private static final HashMap entriesByKey = new HashMap<>(); + + public static void addEntry(int staticID, GameObjectType objectType, String key) { + + ManifestEntry entry = new ManifestEntry(staticID, objectType.name(), key); + + if (entriesByKey.containsKey(key)) + throw new IllegalStateException("Static manifest key " + key + " is used twice"); + + entries.add(entry); + entriesByID.put(staticID, entry); + entriesByKey.put(key, entry); + } + + // Logs moved, vanished and re-targeted IDs against the previous manifest, then replaces it (old copy kept as .prev). + + public static void writeManifest(int mapID) { + + File manifestFile = new File(mapPath + mapID + ".manifest"); + File previousFile = new File(mapPath + mapID + ".manifest.prev"); + HashMap previousByKey; + StringBuilder manifestText = new StringBuilder(); + + previousByKey = readManifest(manifestFile, mapID); + + if (previousByKey != null) + diffManifest(previousByKey); + + manifestText.append("{\n \"map\": ").append(mapID).append(",\n \"count\": ").append(entries.size()) + .append(",\n \"statics\": [\n"); + + for (int i = 0; i < entries.size(); i++) { + manifestText.append(" ").append(entries.get(i).toJson()); + manifestText.append(i < entries.size() - 1 ? ",\n" : "\n"); + } + + manifestText.append(" ]\n}\n"); + + try { + if (manifestFile.exists()) + Files.copy(manifestFile.toPath(), previousFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + Files.write(manifestFile.toPath(), manifestText.toString().getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + Logger.error("Static manifest not written: " + manifestFile.getAbsolutePath() + ": " + e); + return; + } + + Logger.info("Static manifest: " + entries.size() + " static IDs written to " + manifestFile.getName()); + } + + // Logs every negative DB reference (below -1, which means "none") that no loaded static object of the right type holds. + + public static void logOrphanReferences() { + + HashMap negativeValues; + ManifestEntry entry; + String expectedType; + String problem; + int orphanColumns = 0; + int orphanRows = 0; + + for (String[] referenceColumn : referenceColumns) { + + expectedType = referenceColumn[2]; + negativeValues = DbManager.StaticWorldQueries.GET_NEGATIVE_REFERENCES(referenceColumn[0], referenceColumn[1]); + + if (negativeValues == null) + continue; + + for (Map.Entry negativeValue : negativeValues.entrySet()) { + + entry = entriesByID.get(negativeValue.getKey()); + + if (entry == null) + problem = "no static object"; + else if (DbManager.getFromCache(GameObjectType.valueOf(entry.type), entry.id) == null) + problem = entry.type + " " + entry.key + " is not loaded"; + else if (expectedType != null && !expectedType.equals(entry.type)) + problem = "points at " + entry.type + " " + entry.key + ", expected " + expectedType; + else + continue; + + Logger.warn("Orphan reference: " + referenceColumn[0] + "." + referenceColumn[1] + " = " + + negativeValue.getKey() + " on " + negativeValue.getValue() + " rows: " + problem); + orphanColumns++; + orphanRows += negativeValue.getValue(); + } + } + + Logger.info("Static orphan check: " + orphanColumns + " orphan values on " + orphanRows + " rows"); + } + + private static HashMap readManifest(File manifestFile, int mapID) { + + HashMap previousByKey = new HashMap<>(); + JSONObject manifestJson; + JSONArray staticsJson; + JSONObject entryJson; + + if (!manifestFile.exists()) { + Logger.info("Static manifest: no previous " + manifestFile.getName() + ", nothing to diff"); + return null; + } + + try { + manifestJson = CacheParserUtil.readJsonFile(manifestFile); + + if (manifestJson.getInt("map") != mapID) { + Logger.error(manifestFile.getName() + ": map " + manifestJson.getInt("map") + " does not match MB_WORLD_MAPID " + mapID); + return null; + } + + staticsJson = manifestJson.getJSONArray("statics"); + + for (int i = 0; i < staticsJson.length(); i++) { + entryJson = staticsJson.getJSONObject(i); + previousByKey.put(entryJson.getString("key"), + new ManifestEntry(entryJson.getInt("id"), entryJson.getString("type"), entryJson.getString("key"))); + } + + } catch (Exception e) { + Logger.error("Previous static manifest unreadable, nothing to diff: " + manifestFile.getName() + ": " + e); + return null; + } + + return previousByKey; + } + + // A re-targeted ID is the dangerous case: player references to it now silently point at a different object. + + private static void diffManifest(HashMap previousByKey) { + + HashMap previousByID = new HashMap<>(); + ManifestEntry currentEntry; + ManifestEntry previousEntry; + int moved = 0; + int vanished = 0; + int retargeted = 0; + int added = 0; + + for (ManifestEntry entry : previousByKey.values()) + previousByID.put(entry.id, entry); + + for (ManifestEntry entry : previousByKey.values()) { + + currentEntry = entriesByKey.get(entry.key); + + if (currentEntry == null) { + Logger.warn("Static manifest: " + entry.type + " " + entry.key + " (was " + entry.id + ") vanished"); + vanished++; + } else if (currentEntry.id != entry.id) { + Logger.warn("Static manifest: " + entry.type + " " + entry.key + " moved " + entry.id + " -> " + currentEntry.id); + moved++; + } + } + + for (ManifestEntry entry : entries) { + + previousEntry = previousByID.get(entry.id); + + if (!previousByKey.containsKey(entry.key)) + added++; + + if (previousEntry != null && !previousEntry.key.equals(entry.key)) { + Logger.warn("Static manifest: ID " + entry.id + " re-targeted from " + previousEntry.type + " " + + previousEntry.key + " to " + entry.type + " " + entry.key); + retargeted++; + } + } + + Logger.info("Static manifest diff: " + moved + " moved, " + vanished + " vanished, " + retargeted + + " re-targeted, " + added + " new"); + } + + private static class ManifestEntry { + + private final int id; + private final String type; + private final String key; + + private ManifestEntry(int id, String type, String key) { + this.id = id; + this.type = type; + this.key = key; + } + + private String toJson() { + return "{\"id\": " + id + ", \"type\": " + JSONObject.quote(type) + ", \"key\": " + JSONObject.quote(key) + "}"; + } + } + +} diff --git a/src/engine/objects/Guild.java b/src/engine/objects/Guild.java index 361cb34a..612de00e 100644 --- a/src/engine/objects/Guild.java +++ b/src/engine/objects/Guild.java @@ -176,7 +176,8 @@ public class Guild extends AbstractWorldObject { Guild guild = new Guild(); - guild.setObjectUUID(DbManager.nextStaticID()); + guild.setObjectUUID(DbManager.nextStaticID(GameObjectType.Guild, + "guild:" + Integer.toUnsignedString(seedEntry.guildKeys.get(0)))); guild.setStaticWorldObject(true); guild.name = seedEntry.name; guild.charter = seedEntry.charter; diff --git a/src/engine/server/world/WorldServer.java b/src/engine/server/world/WorldServer.java index e2617bfa..c30cbaed 100644 --- a/src/engine/server/world/WorldServer.java +++ b/src/engine/server/world/WorldServer.java @@ -578,6 +578,9 @@ public class WorldServer { Logger.info("Loading all Mobiles"); DbManager.MobQueries.GET_ALL_MOBS(); + StaticManifest.writeManifest(worldMapID); + StaticManifest.logOrphanReferences(); + Logger.info("time to load World Objects: " + (System.currentTimeMillis() - start) + " ms"); // Configure realms for serialization