Implement set_unique_id (#466)

* Implement `set_unique_id`

* save/load watermark, including compat with previous saves (would be `0`)
* metarule to set and reset unique_id
* inventory stacking rules
This commit is contained in:
Mike Klaas
2026-05-26 22:38:40 -07:00
committed by GitHub
parent d42c8456dd
commit cd3adf1621
7 changed files with 272 additions and 11 deletions
+1 -1
View File
@@ -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<br>unjam_lock<br>set_unjam_locks_time | not implemented | - |
| INI settings | get_ini_setting<br>get_ini_string<br>get_ini_section<br>get_ini_sections<br>get_ini_config<br>get_ini_config_db<br>set_ini_setting | ✅ except get_ini_config, get_ini_config_db | `modified_ini` is intentionally omitted as deprecated. |
| Objects and scripts | set_self<br>set_dude_obj<br>real_dude_obj<br>remove_script<br>get/set_script<br>obj_is_carrying_obj<br>loot_obj<br>dialog_obj<br>obj_under_cursor<br>get/set_object_data<br>get/set_flags<br>set_unique_id<br>set_scr_name<br>obj_is_openable<br>get/set_proto_data<br>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<br>set_dude_obj<br>real_dude_obj<br>remove_script<br>get/set_script<br>obj_is_carrying_obj<br>loot_obj<br>dialog_obj<br>obj_under_cursor<br>get/set_object_data<br>get/set_flags<br>set_unique_id<br>set_scr_name<br>obj_is_openable<br>get/set_proto_data<br>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<br>stop/resume_game<br>mark_movie_played<br>game_loaded<br>get_game_mode<br>get_uptime<br>signal_close_game | implemented: game_loaded, get_game_mode, get_uptime, signal_close_game | - |
| Gameplay tweaks | set_pickpocket_max<br>set_hit_chance_max<br>set_xp_mod<br>set_critter_hit_chance_mod<br>set_base_hit_chance_mod<br>set_hp_per_level_mod<br>gdialog_get_barter_mod<br>get/set_unspent_ap_bonus<br>get/set_unspent_ap_perk_bonus<br>set_base_pickpocket_mod<br>set_critter_pickpocket_mod<br>get/set_inven_ap_cost<br>set_drugs_data<br>get_kill_counter<br>mod_kill_counter<br>set_pipboy_available | implemented: gdialog_get_barter_mod, get/set_unspent_ap{_perk}_bonus, get/set_inven_ap_cost | - |
| NPCs | inc_npc_level<br>get_npc_level<br>npc_engine_level_up | not implemented | - |
+136
View File
@@ -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
+12
View File
@@ -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;
}
+69
View File
@@ -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);
+9
View File
@@ -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);
+27 -9
View File
@@ -1,11 +1,13 @@
#include "sfall_ext.h"
#include <algorithm>
#include <cstdint>
#include <string>
#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;
}
+18 -1
View File
@@ -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) {