diff --git a/SFALL_COMPATIBILITY.md b/SFALL_COMPATIBILITY.md
index 99a8bd8f..ce68d89a 100644
--- a/SFALL_COMPATIBILITY.md
+++ b/SFALL_COMPATIBILITY.md
@@ -82,7 +82,7 @@ See [`https://sfall-team.github.io/sfall/`](https://sfall-team.github.io/sfall/)
| Interface / Cursor | get/set_cursor_mode | ✅ | - |
| Locks | lock_is_jammed
unjam_lock
set_unjam_locks_time | not implemented | - |
| INI settings | get_ini_setting
get_ini_string
get_ini_section
get_ini_sections
get_ini_config
get_ini_config_db
set_ini_setting | ✅ except get_ini_config, get_ini_config_db | `modified_ini` is intentionally omitted as deprecated. |
-| Objects and scripts | set_self
set_dude_obj
real_dude_obj
remove_script
get/set_script
obj_is_carrying_obj
loot_obj
dialog_obj
obj_under_cursor
get/set_object_data
get/set_flags
set_unique_id
set_scr_name
obj_is_openable
get/set_proto_data
get_object_ai_data | implemented: set_self, get/set/remove_script, obj_is_carrying_obj, loot_obj, dialog_obj, obj_under_cursor, get_object_data, get_flags, set_flags, obj_is_openable, get_proto_data, set_proto_data | - |
+| Objects and scripts | set_self
set_dude_obj
real_dude_obj
remove_script
get/set_script
obj_is_carrying_obj
loot_obj
dialog_obj
obj_under_cursor
get/set_object_data
get/set_flags
set_unique_id
set_scr_name
obj_is_openable
get/set_proto_data
get_object_ai_data | implemented: set_self, get/set/remove_script, obj_is_carrying_obj, loot_obj, dialog_obj, obj_under_cursor, get_object_data, get_flags, set_flags, set_unique_id, obj_is_openable, get_proto_data, set_proto_data | - |
| Other / Game management | set_movie_path
stop/resume_game
mark_movie_played
game_loaded
get_game_mode
get_uptime
signal_close_game | implemented: game_loaded, get_game_mode, get_uptime, signal_close_game | - |
| Gameplay tweaks | set_pickpocket_max
set_hit_chance_max
set_xp_mod
set_critter_hit_chance_mod
set_base_hit_chance_mod
set_hp_per_level_mod
gdialog_get_barter_mod
get/set_unspent_ap_bonus
get/set_unspent_ap_perk_bonus
set_base_pickpocket_mod
set_critter_pickpocket_mod
get/set_inven_ap_cost
set_drugs_data
get_kill_counter
mod_kill_counter
set_pipboy_available | implemented: gdialog_get_barter_mod, get/set_unspent_ap{_perk}_bonus, get/set_inven_ap_cost | - |
| NPCs | inc_npc_level
get_npc_level
npc_engine_level_up | not implemented | - |
diff --git a/sfall_testing/gl_test_unique_id.ssl b/sfall_testing/gl_test_unique_id.ssl
new file mode 100644
index 00000000..96294a21
--- /dev/null
+++ b/sfall_testing/gl_test_unique_id.ssl
@@ -0,0 +1,136 @@
+#include "define_lite.h"
+#include "sfall.h"
+#include "dik.h"
+#include "define_extra.h"
+#include "lib.inven.h"
+#include "test_utils.h"
+
+#define TEST_ITEM_PID 40
+#define PARTY_UNIQUE_ID_LOWER_BOUND 18000
+#define UNIQUE_ID_LOWER_BOUND 0x10000000
+
+procedure find_random_inventory_item_for_unique_id begin
+ variable items;
+ variable length;
+ variable start;
+ variable scan;
+ variable index;
+ variable item;
+ variable item_id;
+
+ items := inven_as_array(dude_obj);
+ length := len_array(items);
+ if (length <= 0) then return 0;
+
+ start := random(0, length - 1);
+ scan := 0;
+ while (scan < length) do begin
+ index := start + scan;
+ if (index >= length) then index -= length;
+
+ item := items[index];
+ item_id := get_object_data(item, OBJ_DATA_ID);
+ if (item_id < PARTY_UNIQUE_ID_LOWER_BOUND) then begin
+ return item;
+ end
+
+ scan += 1;
+ end
+
+ return items[start];
+end
+
+procedure assign_unique_id_to_random_inventory_item begin
+ variable item;
+ variable old_id;
+ variable new_id;
+
+ item := find_random_inventory_item_for_unique_id;
+ if (item == 0) then begin
+ display_msg("unique_id manual test: inventory is empty");
+ return;
+ end
+
+ old_id := get_object_data(item, OBJ_DATA_ID);
+ new_id := set_unique_id(item);
+
+ if (new_id == old_id) then begin
+ display_msg(string_format3("unique_id manual U: %s pid=%d id unchanged=%d", obj_name(item), obj_pid(item), new_id));
+ end else begin
+ display_msg(string_format4("unique_id manual U: %s pid=%d %d->%d", obj_name(item), obj_pid(item), old_id, new_id));
+ end
+end
+
+procedure keypress_handler begin
+ variable pressed := get_sfall_arg_at(0);
+ variable key := get_sfall_arg_at(1);
+
+ if (not pressed) then return;
+
+ if (key == DIK_U) then begin
+ call assign_unique_id_to_random_inventory_item();
+ end
+end
+
+procedure start begin
+ variable item_a;
+ variable item_b;
+ variable item_c;
+ variable baseline_total;
+ variable unique_id_a;
+ variable unique_id_a_again;
+ variable unique_id_c;
+ variable engine_id_a;
+ variable regular_count;
+ variable total;
+
+ if (not game_loaded) then return;
+
+ display_msg("Testing set_unique_id...");
+
+ call assertEquals("metarule exists", metarule_exist("set_unique_id"), 1);
+
+ baseline_total := obj_is_carrying_obj_pid(dude_obj, TEST_ITEM_PID);
+ item_a := create_object_sid(TEST_ITEM_PID, 0, 0, -1);
+ item_b := create_object_sid(TEST_ITEM_PID, 0, 0, -1);
+ item_c := create_object_sid(TEST_ITEM_PID, 0, 0, -1);
+
+ unique_id_a := set_unique_id(item_a);
+ call assertTrue("unique id lower bound", unique_id_a >= UNIQUE_ID_LOWER_BOUND);
+ call assertEquals("unique id stored on object", get_object_data(item_a, OBJ_DATA_ID), unique_id_a);
+
+ unique_id_a_again := set_unique_id(item_a);
+ call assertEquals("existing unique id reused", unique_id_a_again, unique_id_a);
+
+ add_obj_to_inven(dude_obj, item_a);
+ add_obj_to_inven(dude_obj, item_b);
+ call assertEquals("unique item stays separate", obj_is_carrying_obj(dude_obj, item_a), 1);
+ regular_count := obj_is_carrying_obj(dude_obj, item_b);
+ call assertTrue("regular item still exists after unique item add", regular_count >= 1);
+ total := obj_is_carrying_obj_pid(dude_obj, TEST_ITEM_PID);
+ call assertEquals("unique and regular items add two total items", total, baseline_total + 2);
+
+ unique_id_c := set_unique_id(item_c);
+ call assertNotEquals("second unique item gets new id", unique_id_c, unique_id_a);
+ add_obj_to_inven(dude_obj, item_c);
+ call assertEquals("second unique item stays separate", obj_is_carrying_obj(dude_obj, item_c), 1);
+
+ total := obj_is_carrying_obj_pid(dude_obj, TEST_ITEM_PID);
+ call assertEquals("pid total counts all three added items", total, baseline_total + 3);
+
+ engine_id_a := unset_unique_id(item_a);
+ call assertTrue("unset returns engine id", engine_id_a < UNIQUE_ID_LOWER_BOUND);
+ call assertNotEquals("unset replaces unique id", engine_id_a, unique_id_a);
+ call assertEquals("engine id stored on object", get_object_data(item_a, OBJ_DATA_ID), engine_id_a);
+
+ call assertEquals("remove unique item a", rm_mult_objs_from_inven(dude_obj, item_a, 1), 1);
+ call assertEquals("remove regular item b", rm_mult_objs_from_inven(dude_obj, item_b, 1), 1);
+ call assertEquals("remove unique item c", rm_mult_objs_from_inven(dude_obj, item_c, 1), 1);
+ destroy_object(item_a);
+ destroy_object(item_b);
+ destroy_object(item_c);
+
+ call report_test_results("unique_id");
+ display_msg("unique_id manual test ready: press U to assign/print a unique id for a random inventory item");
+ register_hook_proc(HOOK_KEYPRESS, keypress_handler);
+end
diff --git a/src/item.cc b/src/item.cc
index 984fe001..de29168e 100644
--- a/src/item.cc
+++ b/src/item.cc
@@ -28,6 +28,7 @@
#include "proto_instance.h"
#include "queue.h"
#include "random.h"
+#include "scripts.h"
#include "sfall_config.h"
#include "sfall_script_hooks.h"
#include "skill.h"
@@ -656,10 +657,21 @@ int itemDropAll(Object* critter, int tile)
// 0x4779F0
static bool _item_identical(Object* item1, Object* item2)
{
+ if (item1 == item2) {
+ // This is mostly to make sure the unique_id check below doesn't falsely return
+ // false when the same item is passed in here. Callers rely on this for checking
+ // for "is same pointer"
+ return true;
+ }
+
if (item1->pid != item2->pid) {
return false;
}
+ if (scriptsIsUniqueObjectId(item1->id) || scriptsIsUniqueObjectId(item2->id)) {
+ return false;
+ }
+
if (item1->sid != item2->sid) {
return false;
}
diff --git a/src/scripts.cc b/src/scripts.cc
index 3f4a4f48..9f6524a1 100644
--- a/src/scripts.cc
+++ b/src/scripts.cc
@@ -193,6 +193,7 @@ static int gScriptsListEntriesLength = 0;
// 0x51C7D4 cur_id
static int gObjectIdCounter = 4;
+static int uniqueObjectIdCounter = OBJECT_ID_UNIQUE_START;
// 0x51C7DC count
static int gCritterProcessingIndex = 0;
@@ -215,6 +216,11 @@ static char* gErrorString = gScriptsErrorText;
// 0x51C7F4 blank_str
static char* gEmptyString = gScriptsEmptyText;
+constexpr int OBJECT_ID_PLAYER = 18000;
+// Party member IDs are assigned as (pid & 0xFFFFFF) + OBJECT_ID_PLAYER.
+constexpr int OBJECT_ID_PARTY_MEMBER_END = OBJECT_ID_PLAYER + 0x01000000;
+constexpr int OBJECT_ID_UNIQUE_END = 0x7FFFFFFF;
+
// 0x664954 scriptState
static unsigned int gScriptsRequests;
@@ -556,6 +562,68 @@ int scriptsNewObjectId()
return gObjectIdCounter;
}
+bool scriptsIsUniqueObjectId(int objectId)
+{
+ return objectId > OBJECT_ID_UNIQUE_START
+ || (objectId >= OBJECT_ID_PLAYER && objectId < OBJECT_ID_PARTY_MEMBER_END);
+}
+
+int scriptsNewUniqueObjectId()
+{
+ if (uniqueObjectIdCounter >= OBJECT_ID_UNIQUE_END) {
+ uniqueObjectIdCounter = OBJECT_ID_UNIQUE_START;
+ }
+
+ uniqueObjectIdCounter++;
+ return uniqueObjectIdCounter;
+}
+
+int scriptsGetUniqueObjectIdCounter()
+{
+ return uniqueObjectIdCounter;
+}
+
+void scriptsResetUniqueObjectIdCounter()
+{
+ uniqueObjectIdCounter = OBJECT_ID_UNIQUE_START;
+}
+
+void scriptsRestoreUniqueObjectIdCounter(int savedCounter)
+{
+ if (savedCounter >= OBJECT_ID_UNIQUE_START && savedCounter <= OBJECT_ID_UNIQUE_END) {
+ uniqueObjectIdCounter = savedCounter;
+ } else {
+ uniqueObjectIdCounter = OBJECT_ID_UNIQUE_START;
+ }
+}
+
+int scriptsSetUniqueObjectId(Object* object)
+{
+ if (object == nullptr) {
+ return -1;
+ }
+
+ if (scriptsIsUniqueObjectId(object->id)) {
+ return object->id;
+ }
+
+ object->id = scriptsNewUniqueObjectId();
+ scriptsSyncObjectId(object);
+ return object->id;
+}
+
+void scriptsSyncObjectId(Object* object)
+{
+ if (object == nullptr || object->sid == -1) {
+ return;
+ }
+
+ Script* script;
+ if (scriptGetScript(object->sid, &script) != -1) {
+ script->ownerId = object->id;
+ }
+}
+
// 0x4A390C
int scriptGetSid(Program* program)
{
@@ -1576,6 +1644,7 @@ int _scr_game_init()
gScriptsEnabled = true;
gGameModeEnabled = 1;
gGameTime = 1;
+ scriptsResetUniqueObjectIdCounter();
gameTimeSetTime(302400);
tickersAdd(_doBkProcesses);
diff --git a/src/scripts.h b/src/scripts.h
index 0040ea23..662d151b 100644
--- a/src/scripts.h
+++ b/src/scripts.h
@@ -24,6 +24,8 @@ namespace fallout {
// 365 * 24 * 60 * 60 * 10
#define GAME_TIME_TICKS_PER_YEAR (315360000)
+constexpr int OBJECT_ID_UNIQUE_START = 0x0FFFFFFF;
+
typedef enum ScriptRequests {
SCRIPT_REQUEST_COMBAT = 0x01,
SCRIPT_REQUEST_TOWN_MAP = 0x02,
@@ -162,6 +164,13 @@ int gameTimeEventProcess(Object* obj, void* data);
int _scriptsCheckGameEvents(int* moviePtr, int window);
int mapUpdateEventProcess(Object* obj, void* data);
int scriptsNewObjectId();
+bool scriptsIsUniqueObjectId(int objectId);
+int scriptsNewUniqueObjectId();
+int scriptsSetUniqueObjectId(Object* object);
+void scriptsSyncObjectId(Object* object);
+int scriptsGetUniqueObjectIdCounter();
+void scriptsResetUniqueObjectIdCounter();
+void scriptsRestoreUniqueObjectIdCounter(int savedCounter);
int scriptGetSid(Program* program);
Object* scriptGetSelf(Program* program);
int scriptSetObjects(int sid, Object* source, Object* target);
diff --git a/src/sfall_ext.cc b/src/sfall_ext.cc
index a0098d54..961f7645 100644
--- a/src/sfall_ext.cc
+++ b/src/sfall_ext.cc
@@ -1,11 +1,13 @@
#include "sfall_ext.h"
#include
+#include
#include
#include "db.h"
#include "debug.h"
#include "platform_compat.h"
+#include "scripts.h"
#include "sfall_arrays.h"
#include "sfall_global_vars.h"
@@ -103,10 +105,15 @@ bool sfallSaveGameData(File* stream)
return false;
}
- // Write zeros for CE-unimplemented fields: nextObjectId, addedYears,
- // fakeTraitsCount, fakePerksCount, fakeSelectablePerksCount
- int zero = 0;
- for (int i = 0; i < 5; i++) {
+ if (fileWriteInt32(stream, scriptsGetUniqueObjectIdCounter()) == -1) {
+ debugPrint("LOADSAVE (SFALL): ** Error saving next object id **\n");
+ return false;
+ }
+
+ // Write zeros for CE-unimplemented fields: addedYears, fakeTraitsCount,
+ // fakePerksCount, fakeSelectablePerksCount
+ int32_t zero = 0;
+ for (int32_t i = 0; i < 4; i++) {
if (fileWrite(&zero, sizeof(zero), 1, stream) != 1) {
debugPrint("LOADSAVE (SFALL): ** Error saving stub fields **\n");
return false;
@@ -133,11 +140,20 @@ bool sfallLoadGameData(File* stream)
return false;
}
- // Skip sections CE doesn't implement: nextObjectId, addedYears,
- // fakeTraitsCount, fakePerksCount, fakeSelectablePerksCount
- int ignored;
- for (int i = 0; i < 5; i++) {
- if (fileRead(&ignored, sizeof(ignored), 1, stream) != 1) return true; // old save, stop gracefully
+ int32_t nextObjectId;
+ if (fileReadInt32(stream, &nextObjectId) == -1) {
+ scriptsRestoreUniqueObjectIdCounter(OBJECT_ID_UNIQUE_START);
+ return true; // old save, stop gracefully
+ }
+
+ // Skip sections CE doesn't implement: addedYears, fakeTraitsCount,
+ // fakePerksCount, fakeSelectablePerksCount
+ int32_t ignored;
+ for (int32_t i = 0; i < 4; i++) {
+ if (fileRead(&ignored, sizeof(ignored), 1, stream) != 1) {
+ scriptsRestoreUniqueObjectIdCounter(nextObjectId);
+ return true; // old save, stop gracefully
+ }
}
if (!sfallArraysLoad(stream)) {
@@ -146,6 +162,8 @@ bool sfallLoadGameData(File* stream)
return false;
}
+ scriptsRestoreUniqueObjectIdCounter(nextObjectId);
+
return true;
}
diff --git a/src/sfall_metarules.cc b/src/sfall_metarules.cc
index 8e3c2098..dac101c0 100644
--- a/src/sfall_metarules.cc
+++ b/src/sfall_metarules.cc
@@ -71,6 +71,7 @@ static void mf_set_cursor_mode(OpcodeContext& ctx);
static void mf_set_flags(OpcodeContext& ctx);
static void mf_set_iface_tag_text(OpcodeContext& ctx);
static void mf_set_outline(OpcodeContext& ctx);
+static void mf_set_unique_id(OpcodeContext& ctx);
static void mf_show_window(OpcodeContext& ctx);
static void mf_signal_close_game(OpcodeContext& ctx);
static void mf_tile_by_position(OpcodeContext& ctx);
@@ -172,7 +173,7 @@ const MetaruleInfo kMetarules[] = {
// {"set_selectable_perk_npc", mf_set_selectable_perk_npc, 5, 5, -1, {ARG_OBJECT, ARG_STRING, ARG_INT, ARG_INT, ARG_STRING}},
// {"set_terrain_name", mf_set_terrain_name, 3, 3, -1, {ARG_INT, ARG_INT, ARG_STRING}},
// {"set_town_title", mf_set_town_title, 2, 2, -1, {ARG_INT, ARG_STRING}},
- // {"set_unique_id", mf_set_unique_id, 1, 2, -1, {ARG_OBJECT, ARG_INT}},
+ { "set_unique_id", mf_set_unique_id, 1, 2, -1, { ARG_OBJECT, ARG_INT } },
// {"set_unjam_locks_time", mf_set_unjam_locks_time, 1, 1, -1, {ARG_INT}},
// {"set_window_flag", mf_set_window_flag, 3, 3, -1, {ARG_INTSTR, ARG_INT, ARG_INT}},
{ "show_window", mf_show_window, 0, 1, -1, { ARG_STRING } },
@@ -509,6 +510,22 @@ void mf_set_outline(OpcodeContext& ctx)
object->outline = outline;
}
+void mf_set_unique_id(OpcodeContext& ctx)
+{
+ Object* object = ctx.arg(0).asObject();
+ if (ctx.numArgs() > 1 && ctx.arg(1).asInt() == -1) {
+ // unassign unique_id only if it has one
+ if (object->id > OBJECT_ID_UNIQUE_START) {
+ object->id = scriptsNewObjectId();
+ scriptsSyncObjectId(object);
+ }
+ ctx.setReturn(object->id);
+ return;
+ }
+
+ ctx.setReturn(scriptsSetUniqueObjectId(object));
+}
+
void mf_show_window(OpcodeContext& ctx)
{
if (ctx.numArgs() == 0) {