diff --git a/README.md b/README.md index e228e7b..536afab 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io -Repo: https://forge.makearmy.io/woofmeow/straywild +Repo: https://forge.makearmy.io/woofmeow/netfishing -Releases: https://forge.makearmy.io/woofmeow/straywild/releases +Releases: https://forge.makearmy.io/woofmeow/netfishing/releases ## Installing release builds diff --git a/docs/PORTMASTER.md b/docs/PORTMASTER.md index 3c75a80..803f72f 100644 --- a/docs/PORTMASTER.md +++ b/docs/PORTMASTER.md @@ -47,6 +47,10 @@ directory. The templates in `scripts/portmaster/` are authoritative. upgraded. Device-local data, configuration, and cache remain under `straywild/conf/data`, `straywild/conf/config`, and `straywild/conf/cache`. +- A rebrand upgrade copies an existing `netfishing/conf/` tree into the new + `straywild/conf/` tree when the latter does not exist. It also reuses an + existing `ports/saves/netfishing/` data root rather than creating an empty + replacement. New installations use straywild paths exclusively. - The archive must not contain `conf/`, saves, identities, logs, source files, `.git`, or `.godot` content. - Release downloads must publish only the canonical `straywild.zip` for this @@ -101,9 +105,9 @@ device directory and run: --offline --no-check install ./straywild.zip ``` -Before upgrading an existing installation, preserve -`/mnt/mmc/ports/straywild/conf/` and `/mnt/mmc/ports/saves/straywild/` on the -device. After installation: +Before upgrading an existing installation, preserve either the current +`/mnt/mmc/ports/straywild/` paths or the former `/mnt/mmc/ports/netfishing/` +paths on the device. After installation: 1. Confirm the installed executable and PCK hashes match the staged release. 2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to diff --git a/docs/README.md b/docs/README.md index 444ebc7..5327b30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,10 +17,10 @@ for local README files. ## Related repositories -- [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md) +- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md) stays with the service implementation and defines its versioned HTTP contract. -- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/straywild-dedicated-server/src/branch/main/README.md) +- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md) stays with its installer and update scripts. It consumes this repository's attribution and legal notices from the exact pinned game commit instead of maintaining copies. diff --git a/export_presets.cfg b/export_presets.cfg index 9ae60f2..f16af2d 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -105,7 +105,7 @@ script_export_mode=2 custom_template/debug="" custom_template/release="" -application/bundle_identifier="io.woofmeow.straywild" +application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon_interpolation=0 application/short_version="0.18.1" @@ -178,7 +178,7 @@ architectures/x86=false architectures/x86_64=false version/code=180100 version/name="v0.18.1-alpha" -package/unique_name="io.woofmeow.straywild" +package/unique_name="io.woofmeow.netfishing" package/name="straywild" package/signed=true package/app_category=0 diff --git a/main/main.gd b/main/main.gd index 460eb9e..442c97c 100644 --- a/main/main.gd +++ b/main/main.gd @@ -277,6 +277,7 @@ var _rain_ambience: RainAmbienceType func _ready() -> void: + _data_root.prepare_legacy_user_data() _performance_profile = RuntimePerformanceProfileType.from_environment() _dedicated_runtime = _is_dedicated_server_runtime() if _dedicated_runtime: @@ -471,10 +472,7 @@ func _start_dedicated_server() -> void: _fail_dedicated_server(config.error_message) return var data_path: String = config.data_directory - var manifest_path: String = data_path.path_join( - PlayerDataRoot.MANIFEST_FILENAME - ) - if not FileAccess.file_exists(manifest_path): + if not _data_root.has_data_manifest(data_path): var created: Dictionary = _data_root.create_unbound_root(data_path) if not bool(created.get("ok", false)): _fail_dedicated_server(str(created.get( @@ -973,6 +971,7 @@ func _initialize_application(dedicated: bool) -> void: _network_session, _interface_fonts, ) + _game_ui.data_root_changed.connect(_on_data_root_changed) _game_ui.setup_controller_mapping(_controller_mapping_manager) _ui_pixelation.setup_controller_mapping(_controller_mapping_manager) _game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager) @@ -1344,6 +1343,18 @@ func _on_data_root_status(message: String) -> void: _configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button()) +func _on_data_root_changed() -> void: + _save_manager.set_autosave_enabled(false) + _save_manager.cancel_pending_autosave() + _configure_portable_stores() + if not _network_profile_service.reload_persisted_profile(): + push_warning("The selected data folder's player profile could not be loaded.") + _network_session.set_local_appearance_snapshot( + _appearance_store.get_snapshot() + ) + _game_ui.get_title_screen().refresh_after_data_root_change() + + func _on_server_trust_required( endpoint: String, expected_fingerprint: String, diff --git a/main/runtime_performance_profile.gd b/main/runtime_performance_profile.gd index 8cc1ea6..f18e63a 100644 --- a/main/runtime_performance_profile.gd +++ b/main/runtime_performance_profile.gd @@ -5,6 +5,10 @@ const PROFILE_ENVIRONMENT_VARIABLE: String = ( "straywild_PERFORMANCE_PROFILE" ) const LEGACY_LIGHT_ENVIRONMENT_VARIABLE: String = "straywild_LOW_END" +const NETFISHING_PROFILE_ENVIRONMENT_VARIABLE := ( + "NETFISHING_PERFORMANCE_PROFILE" +) +const NETFISHING_LIGHT_ENVIRONMENT_VARIABLE := "NETFISHING_LOW_END" const NORMAL_PROFILE: StringName = &"normal" const LIGHT_PROFILE: StringName = &"light" const NORMAL_WORLD_RENDER_SCALE: float = 1.0 @@ -14,9 +18,19 @@ var _profile_name: StringName = NORMAL_PROFILE static func from_environment() -> RuntimePerformanceProfile: + var profile_name: String = OS.get_environment( + PROFILE_ENVIRONMENT_VARIABLE + ) + if profile_name.is_empty(): + profile_name = OS.get_environment( + NETFISHING_PROFILE_ENVIRONMENT_VARIABLE + ) return from_name( - StringName(OS.get_environment(PROFILE_ENVIRONMENT_VARIABLE)), - OS.get_environment(LEGACY_LIGHT_ENVIRONMENT_VARIABLE) == "1", + StringName(profile_name), + ( + OS.get_environment(LEGACY_LIGHT_ENVIRONMENT_VARIABLE) == "1" + or OS.get_environment(NETFISHING_LIGHT_ENVIRONMENT_VARIABLE) == "1" + ), ) diff --git a/network/discovery_client.gd b/network/discovery_client.gd index a3d983f..d8e602a 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -21,6 +21,7 @@ signal friend_invite_finished(success: bool, message: String) const BASE_URL_SETTING: String = "network/discovery/base_url" const BASE_URL_ENVIRONMENT: String = "straywild_DISCOVERY_URL" +const LEGACY_BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL" const SETTINGS_PATH: String = "user://network_discovery.cfg" const DEFAULT_ROOM_NAME: String = "Player's Server" const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room" @@ -553,6 +554,10 @@ func is_own_room(room: Dictionary) -> bool: func _configured_base_url() -> String: var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges() + if value.is_empty(): + value = OS.get_environment( + LEGACY_BASE_URL_ENVIRONMENT + ).strip_edges() if value.is_empty(): value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges() while value.ends_with("/"): diff --git a/network/host_ban_store.gd b/network/host_ban_store.gd index f8c4bf9..1251846 100644 --- a/network/host_ban_store.gd +++ b/network/host_ban_store.gd @@ -17,6 +17,10 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _store_path = path _data_root = data_root + _namespaces.clear() + _loaded = false + _write_blocked = false + _expected_hash = "" func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool: diff --git a/network/identity_backup_service.gd b/network/identity_backup_service.gd index 53cf718..c383e7b 100644 --- a/network/identity_backup_service.gd +++ b/network/identity_backup_service.gd @@ -4,6 +4,7 @@ extends Node signal operation_finished(success: bool, message: String) const MAGIC := "straywild_IDENTITY_BACKUP" +const LEGACY_MAGIC := "NETFISHING_IDENTITY_BACKUP" const FORMAT_VERSION := 1 const MAX_BACKUP_BYTES := 256 * 1024 const MIN_PASSPHRASE_LENGTH := 12 @@ -110,8 +111,9 @@ func inspect_backup( if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY: return {"ok": false} var data: Dictionary = json.data + var legacy_backup: bool = data.get("magic") == LEGACY_MAGIC if ( - data.get("magic") != MAGIC + data.get("magic") not in [MAGIC, LEGACY_MAGIC] or data.get("format_version") != FORMAT_VERSION or data.get("identity_type") != expected_type or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM @@ -134,12 +136,23 @@ func inspect_backup( var signature: PackedByteArray = Marshalls.base64_to_raw( str(data.get("self_signature", "")) ) - if not NetworkIdentityCrypto.verify_fields( - NetworkIdentityCrypto.load_public_key(public_pem), - "identity_backup_self_test", - [expected_type, fingerprint], - signature, - ): + var public_key: CryptoKey = NetworkIdentityCrypto.load_public_key(public_pem) + var backup_signature_valid: bool = ( + NetworkIdentityCrypto.verify_legacy_fields( + public_key, + "identity_backup_self_test", + [expected_type, fingerprint], + signature, + ) + if legacy_backup + else NetworkIdentityCrypto.verify_fields( + public_key, + "identity_backup_self_test", + [expected_type, fingerprint], + signature, + ) + ) + if not backup_signature_valid: return {"ok": false} var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields( key, "identity_import_self_test", [fingerprint] diff --git a/network/known_player_store.gd b/network/known_player_store.gd index 0c6b859..1118656 100644 --- a/network/known_player_store.gd +++ b/network/known_player_store.gd @@ -15,6 +15,10 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _store_path = path _data_root = data_root + _records.clear() + _loaded = false + _write_blocked = false + _expected_hash = "" func observe(fingerprint: String, display_name: String) -> String: diff --git a/network/network_identity_crypto.gd b/network/network_identity_crypto.gd index d3f9983..9d4f09c 100644 --- a/network/network_identity_crypto.gd +++ b/network/network_identity_crypto.gd @@ -6,6 +6,7 @@ const FINGERPRINT_LENGTH: int = 64 const MAX_PUBLIC_KEY_BYTES: int = 8192 const MAX_SIGNATURE_BYTES: int = 1024 const DOMAIN_PREFIX: String = "straywild" +const LEGACY_DOMAIN_PREFIX: String = "NETFISHING" const IDENTITY_VERSION: String = "identity_v1" @@ -50,8 +51,16 @@ static func secure_id(byte_count: int = 16) -> String: static func canonical_bytes(domain: String, fields: Array) -> PackedByteArray: + return canonical_bytes_for_prefix(DOMAIN_PREFIX, domain, fields) + + +static func canonical_bytes_for_prefix( + prefix: String, + domain: String, + fields: Array, +) -> PackedByteArray: var output := PackedByteArray() - _append_string(output, DOMAIN_PREFIX) + _append_string(output, prefix) _append_string(output, IDENTITY_VERSION) _append_string(output, domain) for value: Variant in fields: @@ -117,6 +126,37 @@ static func verify_fields( ) +static func verify_legacy_fields( + public_key: CryptoKey, + domain: String, + fields: Array, + signature: PackedByteArray, +) -> bool: + if ( + public_key == null + or signature.is_empty() + or signature.size() > MAX_SIGNATURE_BYTES + ): + return false + var bytes: PackedByteArray = canonical_bytes_for_prefix( + LEGACY_DOMAIN_PREFIX, + domain, + fields, + ) + if bytes.is_empty(): + return false + var context := HashingContext.new() + if context.start(HashingContext.HASH_SHA256) != OK: + return false + context.update(bytes) + return Crypto.new().verify( + HashingContext.HASH_SHA256, + context.finish(), + signature, + public_key, + ) + + static func load_public_key(public_pem: String) -> CryptoKey: var normalized := normalize_public_pem(public_pem) if normalized.is_empty() or normalized.to_utf8_buffer().size() > MAX_PUBLIC_KEY_BYTES: diff --git a/network/network_profile_preferences.gd b/network/network_profile_preferences.gd index 3c4996b..b6cac63 100644 --- a/network/network_profile_preferences.gd +++ b/network/network_profile_preferences.gd @@ -21,6 +21,8 @@ var _future_version := false func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _profile_path = path _data_root = data_root + _expected_hash = "" + _future_version = false func _temp_path() -> String: diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd index e4732a9..ff967f1 100644 --- a/network/network_profile_service.gd +++ b/network/network_profile_service.gd @@ -51,6 +51,17 @@ func setup( _apply_local_voice_to_avatar() +func reload_persisted_profile() -> bool: + if ( + not _preferences.load_or_create() + or not _appearance_store.load_preferences() + ): + return false + _apply_to_avatar(1, _appearance_store.get_snapshot()) + _apply_local_voice_to_avatar() + return true + + func get_persisted_name() -> String: return _preferences.display_name diff --git a/network/player_data_root.gd b/network/player_data_root.gd index d6153ff..e173577 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -10,11 +10,41 @@ const BOOTSTRAP_VERSION := 1 const MANIFEST_VERSION := 1 const LAYOUT_VERSION := 1 const MANIFEST_FILENAME := "straywild_data.json" +const LEGACY_MANIFEST_FILENAME := "netfishing_data.json" const README_FILENAME := "README.txt" const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR" const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_DATA_DIR" +const LEGACY_ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR" +const LEGACY_CREATE_ENVIRONMENT_VARIABLE := "NETFISHING_CREATE_DATA_DIR" const APPLICATION_ID := "straywild" +const LEGACY_APPLICATION_ID := "netfishing" const APP_DATA_PORTABLE_PATH := "user://portable-data" +const LEGACY_USER_DIRECTORY_NAME := "NETFISHING" +const LEGACY_USER_FILES := [ + "data_root_bootstrap.json", + "data_root_bootstrap.json.backup", + "network_discovery.cfg", + "player_settings.json", + "player_settings.json.backup", + "controller_mappings.json", + "controller_mappings.json.backup", + "keyboard_mouse_bindings.json", + "keyboard_mouse_bindings.json.backup", + "player_save.json", + "network_profile.json", + "player_appearance.json", + "saved_servers.json", + "known_players.json", + "player_relationships.json", + "server_trust.json", + "host_bans.json", + "player_identity.key", + "player_identity.pub", + "player_identity.json", + "host_identity.key", + "host_identity.pub", + "host_identity.json", +] const PATH_ALLOWED: StringName = &"allowed" const PATH_SOURCE_PROJECT: StringName = &"source_project" const PATH_INSTALLATION: StringName = &"installation" @@ -37,23 +67,29 @@ var override_active := false func resolve() -> bool: + prepare_legacy_user_data() _load_bootstrap_identity() var command_line: String = _command_line_override() if not command_line.is_empty(): override_active = true mode = Mode.COMMAND_LINE_OVERRIDE return _activate_existing(command_line, "", false) - if OS.has_environment(ENVIRONMENT_VARIABLE): + var environment_variable: String = _available_environment_variable( + ENVIRONMENT_VARIABLE, + LEGACY_ENVIRONMENT_VARIABLE, + ) + if not environment_variable.is_empty(): override_active = true mode = Mode.ENVIRONMENT_OVERRIDE - var environment_path: String = OS.get_environment(ENVIRONMENT_VARIABLE) + var environment_path: String = OS.get_environment(environment_variable) if not environment_path.is_absolute_path(): return _fail("straywild_DATA_DIR must be an absolute path.") if ( - OS.get_environment(CREATE_ENVIRONMENT_VARIABLE) == "1" - and not FileAccess.file_exists( - environment_path.path_join(MANIFEST_FILENAME) + _environment_flag( + CREATE_ENVIRONMENT_VARIABLE, + LEGACY_CREATE_ENVIRONMENT_VARIABLE, ) + and not has_data_manifest(environment_path) ): var created: Dictionary = create_unbound_root(environment_path) if not bool(created.get("ok", false)): @@ -80,6 +116,30 @@ func resolve() -> bool: return false +func prepare_legacy_user_data() -> void: + var current_root: String = _normalize( + ProjectSettings.globalize_path("user://") + ) + if current_root.is_empty(): + return + var legacy_root: String = _normalize( + current_root.get_base_dir().path_join(LEGACY_USER_DIRECTORY_NAME) + ) + if legacy_root == current_root or not DirAccess.dir_exists_absolute(legacy_root): + return + for filename: String in LEGACY_USER_FILES: + var destination: String = current_root.path_join(filename) + if FileAccess.file_exists(destination): + continue + var source: String = legacy_root.path_join(filename) + if FileAccess.file_exists(source): + _copy_legacy_user_file(source, destination) + + +func has_data_manifest(path: String) -> bool: + return not _existing_manifest_path(_normalize(path)).is_empty() + + func default_visible_path() -> String: var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) if documents.is_empty(): @@ -105,12 +165,14 @@ func select_new_root(path: String, app_data: bool = false) -> bool: normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH) if not _validate_candidate(normalized, true): return false - var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) - if FileAccess.file_exists(manifest_path): + var manifest_path: String = _existing_manifest_path(normalized) + if not manifest_path.is_empty(): var manifest: Dictionary = _read_json(manifest_path) if not _valid_manifest(manifest): return _fail("The selected folder has a malformed straywild manifest.") root_id = str(manifest["root_id"]) + if not _upgrade_legacy_manifest(normalized, manifest, manifest_path): + return _fail("The legacy data-folder manifest could not be upgraded.") else: if not _directory_is_empty(normalized) and not app_data: return _fail( @@ -145,14 +207,14 @@ func create_app_data_layout_for_migration(path: String) -> Dictionary: var normalized: String = _normalize(path) if not _validate_candidate(normalized, false): return {"ok": false, "message": error_message} - var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) - if FileAccess.file_exists(manifest_path): + var manifest_path: String = _existing_manifest_path(normalized) + if not manifest_path.is_empty(): var manifest: Dictionary = _read_json(manifest_path) - return ( - {"ok": true, "root_id": str(manifest.get("root_id", ""))} - if _valid_manifest(manifest) - else {"ok": false, "message": "The app-data manifest is malformed."} - ) + if not _valid_manifest(manifest): + return {"ok": false, "message": "The app-data manifest is malformed."} + if not _upgrade_legacy_manifest(normalized, manifest, manifest_path): + return {"ok": false, "message": "The app-data manifest could not be upgraded."} + return {"ok": true, "root_id": str(manifest.get("root_id", ""))} var id: String = Crypto.new().generate_random_bytes(16).hex_encode() if not _create_layout(normalized, id): return {"ok": false, "message": "The app-data layout could not be created."} @@ -236,12 +298,14 @@ func _activate_existing(path: String, expected_id: String, permit_creation: bool var normalized: String = _normalize(path) if not _validate_candidate(normalized, permit_creation): return false - var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) - if not FileAccess.file_exists(manifest_path): + var manifest_path: String = _existing_manifest_path(normalized) + if manifest_path.is_empty(): return _fail("The selected folder is not a straywild data folder.") var manifest: Dictionary = _read_json(manifest_path) if not _valid_manifest(manifest): return _fail("The straywild data-folder manifest is malformed.") + if not _upgrade_legacy_manifest(normalized, manifest, manifest_path): + return _fail("The legacy data-folder manifest could not be upgraded.") var found_id: String = str(manifest["root_id"]) if not expected_id.is_empty() and expected_id != found_id: return _fail("The selected data folder does not match this device pointer.") @@ -422,12 +486,71 @@ func _valid_manifest(data: Dictionary) -> bool: return ( data.get("format_version") == MANIFEST_VERSION and data.get("layout_version") == LAYOUT_VERSION - and data.get("application") == APPLICATION_ID + and data.get("application") in [APPLICATION_ID, LEGACY_APPLICATION_ID] and typeof(data.get("root_id")) == TYPE_STRING and str(data["root_id"]).length() == 32 ) +func _existing_manifest_path(path: String) -> String: + var current: String = path.path_join(MANIFEST_FILENAME) + if FileAccess.file_exists(current): + return current + var legacy: String = path.path_join(LEGACY_MANIFEST_FILENAME) + return legacy if FileAccess.file_exists(legacy) else "" + + +func _upgrade_legacy_manifest( + path: String, + manifest: Dictionary, + manifest_path: String, +) -> bool: + if ( + manifest_path.get_file() == MANIFEST_FILENAME + and manifest.get("application") == APPLICATION_ID + ): + return true + var upgraded: Dictionary = manifest.duplicate(true) + upgraded["application"] = APPLICATION_ID + upgraded["last_opened_at_unix"] = int(Time.get_unix_time_from_system()) + return _write_text_atomic( + path.path_join(MANIFEST_FILENAME), + JSON.stringify(upgraded, "\t"), + ) + + +func _available_environment_variable(primary: String, legacy: String) -> String: + if OS.has_environment(primary): + return primary + return legacy if OS.has_environment(legacy) else "" + + +func _environment_flag(primary: String, legacy: String) -> bool: + var variable: String = _available_environment_variable(primary, legacy) + return not variable.is_empty() and OS.get_environment(variable) == "1" + + +func _copy_legacy_user_file(source: String, destination: String) -> bool: + var bytes: PackedByteArray = PortableFileGuard.read_bytes( + source, + 16 * 1024 * 1024, + ) + if bytes.is_empty() and FileAccess.get_open_error() != OK: + return false + if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK: + return false + var file: FileAccess = FileAccess.open(destination, FileAccess.WRITE) + if file == null: + return false + file.store_buffer(bytes) + file.flush() + var copied: bool = file.get_error() == OK + file.close() + if copied and source.ends_with(".key"): + FileAccess.set_unix_permissions(destination, 384) + return copied + + func _directory_is_empty(path: String) -> bool: var access: DirAccess = DirAccess.open(path) if access == null: diff --git a/network/player_relationship_store.gd b/network/player_relationship_store.gd index 0f4b6f1..de04d49 100644 --- a/network/player_relationship_store.gd +++ b/network/player_relationship_store.gd @@ -21,6 +21,10 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _store_path = path _data_root = data_root + _records.clear() + _loaded = false + _write_blocked = false + _expected_hash = "" func is_muted(fingerprint: String) -> bool: diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd index e95d883..2de0acb 100644 --- a/network/portable_data_migration.gd +++ b/network/portable_data_migration.gd @@ -26,10 +26,7 @@ static func migrate_legacy_to( ) -> Dictionary: var normalized: String = destination.simplify_path().trim_suffix("/") if DirAccess.dir_exists_absolute(normalized): - var existing_manifest: String = normalized.path_join( - PlayerDataRoot.MANIFEST_FILENAME - ) - if FileAccess.file_exists(existing_manifest): + if data_root.has_data_manifest(normalized): return { "ok": false, "requires_existing_root_decision": true, @@ -106,7 +103,7 @@ static func migrate_active_to( "message": "This data folder is already active.", } if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized): - if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)): + if data_root.has_data_manifest(normalized): return { "ok": false, "requires_existing_root_decision": true, @@ -207,8 +204,7 @@ static func replace_existing_with_legacy( destination: String, ) -> Dictionary: var normalized: String = destination.simplify_path().trim_suffix("/") - var manifest: String = normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME) - if not FileAccess.file_exists(manifest): + if not data_root.has_data_manifest(normalized): return {"ok": false, "message": "The selected folder is not a straywild data root."} var recovery: String = ProjectSettings.globalize_path( "user://migration-recovery" @@ -229,7 +225,7 @@ static func replace_existing_with_active( destination: String, ) -> Dictionary: var normalized: String = destination.simplify_path().trim_suffix("/") - if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)): + if not data_root.has_data_manifest(normalized): return {"ok": false, "message": "The selected folder is not a straywild data root."} var recovery: String = ProjectSettings.globalize_path( "user://migration-recovery" diff --git a/network/saved_server_store.gd b/network/saved_server_store.gd index de48c2c..4e00259 100644 --- a/network/saved_server_store.gd +++ b/network/saved_server_store.gd @@ -22,6 +22,12 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _store_path = path _data_root = data_root + _saved_entries.clear() + _recent_entries.clear() + _loaded = false + _recovery_warning = "" + _write_blocked = false + _expected_hash = "" func _temp_path() -> String: diff --git a/network/server_trust_store.gd b/network/server_trust_store.gd index fda774b..1d4dd29 100644 --- a/network/server_trust_store.gd +++ b/network/server_trust_store.gd @@ -19,6 +19,10 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _store_path = path _data_root = data_root + _records.clear() + _loaded = false + _write_blocked = false + _expected_hash = "" func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification: diff --git a/progression/player_appearance_store.gd b/progression/player_appearance_store.gd index 7ac8767..febe43d 100644 --- a/progression/player_appearance_store.gd +++ b/progression/player_appearance_store.gd @@ -13,6 +13,10 @@ var _data_root: PlayerDataRoot func configure_storage(path: String, data_root: PlayerDataRoot) -> void: _profile_path = path _data_root = data_root + _snapshot = CharacterCustomizationCatalog.default_snapshot() + _loaded = false + _future_version = false + _expected_hash = "" func _temp_path() -> String: diff --git a/save/progression_save_codec.gd b/save/progression_save_codec.gd index a006773..4d0ba33 100644 --- a/save/progression_save_codec.gd +++ b/save/progression_save_codec.gd @@ -2,6 +2,7 @@ class_name ProgressionSaveCodec extends RefCounted const MAGIC := "straywild_PROGRESSION_CONTAINER" +const LEGACY_MAGIC := "NETFISHING_PROGRESSION_CONTAINER" const FORMAT_VERSION := 1 const LOCAL_KIND := "local_save" const ARCHIVE_KIND := "portable_archive" @@ -9,8 +10,10 @@ const MAX_CONTAINER_BYTES := 16 * 1024 * 1024 # This key keeps routine progression files opaque to casual editing. Since the # client is open source, it is intentionally not treated as a security secret. +# Its original value is a stable file-format detail so released saves remain +# readable after the Straywild rebrand. const CONTAINER_PASSPHRASE := ( - "straywild progression container v1 / not an identity credential" + "NETfishing progression container v1 / not an identity credential" ) @@ -102,15 +105,9 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary: or FileAccess.get_size(path) > MAX_CONTAINER_BYTES ): return {"ok": false, "message": "progression file is unavailable."} - var file: FileAccess = FileAccess.open_encrypted_with_pass( - path, - FileAccess.READ, - CONTAINER_PASSPHRASE, - ) - if file == null: + var envelope_text: String = _read_encrypted_text(path) + if envelope_text.is_empty(): return {"ok": false, "message": "progression file could not be opened."} - var envelope_text: String = file.get_as_text() - file.close() var envelope_json := JSON.new() if ( envelope_json.parse(envelope_text) != OK @@ -118,8 +115,9 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary: ): return {"ok": false, "message": "progression container is malformed."} var envelope: Dictionary = envelope_json.data + var legacy_container: bool = envelope.get("magic") == LEGACY_MAGIC if ( - envelope.get("magic") != MAGIC + envelope.get("magic") not in [MAGIC, LEGACY_MAGIC] or int(envelope.get("format_version", -1)) != FORMAT_VERSION or str(envelope.get("kind", "")) != expected_kind ): @@ -144,9 +142,23 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary: "game_version": str(envelope.get("game_version", "")), "created_at_unix": int(envelope.get("created_at_unix", 0)), "legacy_plaintext": false, + "legacy_container": legacy_container, } +static func _read_encrypted_text(path: String) -> String: + var file: FileAccess = FileAccess.open_encrypted_with_pass( + path, + FileAccess.READ, + CONTAINER_PASSPHRASE, + ) + if file == null: + return "" + var text: String = file.get_as_text() + file.close() + return text + + static func _read_plaintext_dictionary(path: String) -> Dictionary: var bytes: PackedByteArray = PortableFileGuard.read_bytes( path, diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index c18f277..9489af2 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -9,7 +9,7 @@ readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" -readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" +readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.18.1-alpha-windows-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.18.1-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh index 286a743..0487b5b 100755 --- a/scripts/build_portmaster.sh +++ b/scripts/build_portmaster.sh @@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" < DedicatedServerConfig: result.discovery_url = str(ProjectSettings.get_setting( "network/discovery/base_url", "" )).strip_edges() - var config_path: String = OS.get_environment( - "straywild_SERVER_CONFIG" + var config_path: String = _environment_string( + "straywild_SERVER_CONFIG", + "NETFISHING_SERVER_CONFIG", + "", ).strip_edges() for argument: String in OS.get_cmdline_user_args(): if argument.begins_with("--config="): @@ -87,36 +89,44 @@ func _load_file(path: String) -> bool: func _apply_environment() -> void: server_name = _environment_string( - "straywild_SERVER_NAME", server_name + "straywild_SERVER_NAME", "NETFISHING_SERVER_NAME", server_name ) bind_address = _environment_string( - "straywild_SERVER_BIND", bind_address + "straywild_SERVER_BIND", "NETFISHING_SERVER_BIND", bind_address + ) + port = _environment_int( + "straywild_SERVER_PORT", "NETFISHING_SERVER_PORT", port ) - port = _environment_int("straywild_SERVER_PORT", port) max_players = _environment_int( - "straywild_SERVER_MAX_PLAYERS", max_players + "straywild_SERVER_MAX_PLAYERS", + "NETFISHING_SERVER_MAX_PLAYERS", + max_players, ) world_seed = _environment_int( - "straywild_WORLD_SEED", world_seed + "straywild_WORLD_SEED", "NETFISHING_WORLD_SEED", world_seed ) public_listing = _environment_bool( - "straywild_SERVER_PUBLIC", public_listing + "straywild_SERVER_PUBLIC", "NETFISHING_SERVER_PUBLIC", public_listing ) discovery_url = _environment_string( - "straywild_DISCOVERY_URL", discovery_url + "straywild_DISCOVERY_URL", "NETFISHING_DISCOVERY_URL", discovery_url ) data_directory = _environment_string( - "straywild_DATA_DIR", data_directory + "straywild_DATA_DIR", "NETFISHING_DATA_DIR", data_directory ) chat_logging = _environment_bool( - "straywild_CHAT_LOGGING", chat_logging + "straywild_CHAT_LOGGING", "NETFISHING_CHAT_LOGGING", chat_logging ) chat_log_path = _environment_string( - "straywild_CHAT_LOG_PATH", chat_log_path + "straywild_CHAT_LOG_PATH", "NETFISHING_CHAT_LOG_PATH", chat_log_path ) - if OS.has_environment("straywild_SERVER_OPERATORS"): + var operators_variable: String = _available_environment_variable( + "straywild_SERVER_OPERATORS", + "NETFISHING_SERVER_OPERATORS", + ) + if not operators_variable.is_empty(): operator_fingerprints = _parse_fingerprint_list( - OS.get_environment("straywild_SERVER_OPERATORS") + OS.get_environment(operators_variable) ) @@ -211,21 +221,45 @@ static func _safe_text(value: String) -> bool: return true -static func _environment_string(name: String, fallback: String) -> String: - return OS.get_environment(name) if OS.has_environment(name) else fallback +static func _available_environment_variable( + name: String, + legacy_name: String, +) -> String: + if OS.has_environment(name): + return name + return legacy_name if OS.has_environment(legacy_name) else "" -static func _environment_int(name: String, fallback: int) -> int: +static func _environment_string( + name: String, + legacy_name: String, + fallback: String, +) -> String: + var variable: String = _available_environment_variable(name, legacy_name) + return OS.get_environment(variable) if not variable.is_empty() else fallback + + +static func _environment_int( + name: String, + legacy_name: String, + fallback: int, +) -> int: + var variable: String = _available_environment_variable(name, legacy_name) return ( - _parse_int(OS.get_environment(name), fallback) - if OS.has_environment(name) else fallback + _parse_int(OS.get_environment(variable), fallback) + if not variable.is_empty() else fallback ) -static func _environment_bool(name: String, fallback: bool) -> bool: - if not OS.has_environment(name): +static func _environment_bool( + name: String, + legacy_name: String, + fallback: bool, +) -> bool: + var variable: String = _available_environment_variable(name, legacy_name) + if variable.is_empty(): return fallback - var value: String = OS.get_environment(name).strip_edges().to_lower() + var value: String = OS.get_environment(variable).strip_edges().to_lower() return value in ["1", "true", "yes", "on"] diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index 7f98066..c3f9569 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -39,6 +39,15 @@ func _run() -> void: var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) assert(bool(decoded.get("ok", false))) assert(int((decoded["data"] as Dictionary)["save_version"]) == 10) + var legacy_container_path: String = data_root.progression_backup_directory().path_join( + "netfishing-container.nfsave" + ) + _assert_legacy_progression_container( + legacy_container_path, + decoded["data"] as Dictionary, + ) + _assert_legacy_data_manifest(data_root.root_path.get_base_dir()) + _assert_legacy_identity_backup(main, data_root) var archive_path: String = data_root.progression_backup_directory().path_join( "progression-test.nfsave" @@ -135,6 +144,9 @@ func _run() -> void: assert(settings_panel.get_node_or_null("%ExportProgression") == null) assert(settings_panel.get_node_or_null("%ImportProgression") == null) var settings_panel: SettingsPanel = settings_panels.front() as SettingsPanel + settings_panel.set( + "_presentation_mode", SettingsPanel.PresentationMode.TITLE_EMBEDDED + ) var unchanged_root: String = data_root.root_path settings_panel.call("_choose_data_folder") var data_folder_dialog := ( @@ -169,11 +181,14 @@ func _run() -> void: var migrated_root: String = data_root.root_path.get_base_dir().path_join( "progression-migrated-data" ) - var data_migration: Dictionary = PortableDataMigration.migrate_active_to( - data_root, migrated_root + settings_panel.call("_change_data_folder", migrated_root) + for _frame: int in 4: + await process_frame + assert(data_root.root_path == migrated_root) + assert( + (settings_panel.get_node("%SettingsFeedback") as Label).text + == "data folder changed." ) - assert(bool(data_migration.get("ok", false))) - assert(bool(data_migration.get("changed", false))) var migrated_save: String = migrated_root.path_join( "player/player_save.nfsave" ) @@ -193,6 +208,18 @@ func _run() -> void: "ok", false ) )) + var live_slots: Array[Dictionary] = save_slots.list_slots() + assert(live_slots.size() == 2) + var live_created: Dictionary = save_slots.create_empty_slot( + "live root switch" + ) + assert(bool(live_created.get("ok", false))) + var live_slot_id: String = str(live_created.get("slot_id", "")) + assert(save_slots.rename_slot(live_slot_id, "live root ready")) + assert( + str(save_slots.get_slot(live_slot_id).get("display_name", "")) + == "live root ready" + ) main.queue_free() for _frame: int in 4: @@ -202,6 +229,124 @@ func _run() -> void: quit() +func _assert_legacy_progression_container( + path: String, + payload: Dictionary, +) -> void: + var payload_json: String = JSON.stringify(payload) + var envelope: Dictionary = { + "magic": ProgressionSaveCodec.LEGACY_MAGIC, + "format_version": ProgressionSaveCodec.FORMAT_VERSION, + "kind": ProgressionSaveCodec.LOCAL_KIND, + "game_version": "0.18.1-alpha", + "created_at_unix": 1, + "payload_sha256": PortableFileGuard.hash_bytes( + payload_json.to_utf8_buffer() + ), + "payload_json": payload_json, + } + var file: FileAccess = FileAccess.open_encrypted_with_pass( + path, + FileAccess.WRITE, + ProgressionSaveCodec.CONTAINER_PASSPHRASE, + ) + assert(file != null) + file.store_string(JSON.stringify(envelope)) + file.close() + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(path) + assert(bool(decoded.get("ok", false))) + assert(bool(decoded.get("legacy_container", false))) + + +func _assert_legacy_data_manifest(parent: String) -> void: + var legacy_root: String = parent.path_join("netfishing-manifest-root") + assert(DirAccess.make_dir_recursive_absolute(legacy_root) == OK) + var legacy_id: String = "0123456789abcdef0123456789abcdef" + var legacy_manifest: Dictionary = { + "format_version": PlayerDataRoot.MANIFEST_VERSION, + "layout_version": PlayerDataRoot.LAYOUT_VERSION, + "application": PlayerDataRoot.LEGACY_APPLICATION_ID, + "root_id": legacy_id, + "created_at_unix": 1, + "last_opened_at_unix": 1, + } + var legacy_path: String = legacy_root.path_join( + PlayerDataRoot.LEGACY_MANIFEST_FILENAME + ) + var file := FileAccess.open(legacy_path, FileAccess.WRITE) + assert(file != null) + file.store_string(JSON.stringify(legacy_manifest, "\t")) + file.close() + var legacy_data_root := PlayerDataRoot.new() + root.add_child(legacy_data_root) + assert(legacy_data_root.use_existing_root(legacy_root)) + assert(legacy_data_root.root_id == legacy_id) + assert(FileAccess.file_exists(legacy_path)) + var canonical_path: String = legacy_root.path_join( + PlayerDataRoot.MANIFEST_FILENAME + ) + assert(FileAccess.file_exists(canonical_path)) + var parser := JSON.new() + assert(parser.parse(FileAccess.get_file_as_string(canonical_path)) == OK) + assert((parser.data as Dictionary).get("application") == PlayerDataRoot.APPLICATION_ID) + legacy_data_root.queue_free() + + +func _assert_legacy_identity_backup(main: Node, data_root: PlayerDataRoot) -> void: + var player_identity := main.get("_player_identity") as PlayerIdentityStore + var identity_backups := main.get("_identity_backups") as IdentityBackupService + assert(player_identity != null and player_identity.is_ready()) + assert(identity_backups != null) + var material: Dictionary = player_identity.export_identity_material() + var fingerprint: String = str(material.get("fingerprint", "")) + var private_key := CryptoKey.new() + assert(private_key.load_from_string(str(material.get("private_pem", ""))) == OK) + var canonical: PackedByteArray = NetworkIdentityCrypto.canonical_bytes_for_prefix( + NetworkIdentityCrypto.LEGACY_DOMAIN_PREFIX, + "identity_backup_self_test", + ["player", fingerprint], + ) + var context := HashingContext.new() + assert(context.start(HashingContext.HASH_SHA256) == OK) + context.update(canonical) + var signature: PackedByteArray = Crypto.new().sign( + HashingContext.HASH_SHA256, + context.finish(), + private_key, + ) + var envelope: Dictionary = { + "magic": IdentityBackupService.LEGACY_MAGIC, + "format_version": IdentityBackupService.FORMAT_VERSION, + "identity_type": "player", + "algorithm": NetworkIdentityCrypto.ALGORITHM, + "private_pem": material.get("private_pem", ""), + "public_pem": material.get("public_pem", ""), + "fingerprint": fingerprint, + "created_at_unix": 1, + "source_device_id": data_root.device_id, + "self_signature": Marshalls.raw_to_base64(signature), + } + var backup_path: String = data_root.identity_backup_directory().path_join( + "netfishing-player.nfidentity" + ) + var passphrase := "legacy-test-passphrase" + var file: FileAccess = FileAccess.open_encrypted_with_pass( + backup_path, + FileAccess.WRITE, + passphrase, + ) + assert(file != null) + file.store_string(JSON.stringify(envelope)) + file.close() + var inspected: Dictionary = identity_backups.inspect_backup( + backup_path, + passphrase, + "player", + ) + assert(bool(inspected.get("ok", false))) + assert(str(inspected.get("fingerprint", "")) == fingerprint) + + func _assert_opaque(path: String) -> void: var bytes: PackedByteArray = PortableFileGuard.read_bytes(path) assert(not bytes.is_empty()) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 6efea59..6e4241b 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -81,6 +81,7 @@ signal shop_backdrop_visibility_changed(is_visible: bool) signal virtual_pointer_mode_changed(is_active: bool) signal social_prompt_accepted signal social_prompt_declined +signal data_root_changed const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" @@ -271,6 +272,8 @@ func _ready() -> void: _pause_settings_panel.crisp_reset_focus_requested.connect( crisp_reset_focus_requested.emit ) + _title_settings_panel.data_root_changed.connect(data_root_changed.emit) + _pause_settings_panel.data_root_changed.connect(data_root_changed.emit) if not Input.joy_connection_changed.is_connected( _on_controller_connection_changed ): diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 159b3c0..7055d96 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -40,6 +40,7 @@ signal opened signal crisp_reset_focus_requested signal panel_visibility_changed(is_visible: bool) signal navigation_transition_started +signal data_root_changed enum PresentationMode { TITLE_EMBEDDED, @@ -612,6 +613,7 @@ func _refresh_data_page() -> void: %DataStorageMode.text = "storage mode: " + _data_root.storage_mode_text() %ChangeDataFolder.disabled = ( _data_root.override_active + or _presentation_mode != PresentationMode.TITLE_EMBEDDED or (_network_session != null and _network_session.is_session_active()) ) var fingerprint: String = ( @@ -668,6 +670,9 @@ func _choose_data_folder() -> void: if _data_root == null or _data_root.override_active: _feedback.text = "the data folder is externally managed." return + if _presentation_mode != PresentationMode.TITLE_EMBEDDED: + _feedback.text = "return to title to change the data folder." + return if _network_session != null and _network_session.is_session_active(): _feedback.text = "return to title to change the data folder." return @@ -684,6 +689,12 @@ func _choose_data_folder() -> void: func _change_data_folder(path: String) -> void: + if ( + _presentation_mode != PresentationMode.TITLE_EMBEDDED + or (_network_session != null and _network_session.is_session_active()) + ): + _feedback.text = "return to title to change the data folder." + return var result: Dictionary = PortableDataMigration.migrate_active_to( _data_root, path ) @@ -697,9 +708,7 @@ func _change_data_folder(path: String) -> void: ).to_lower() _refresh_data_page() return - _feedback.text = "data folder changed. straywild will close safely." - _refresh_data_page() - get_tree().call_deferred("quit") + _complete_data_root_change("data folder changed.") else: _feedback.text = str( result.get("message", "could not change the data folder.") @@ -717,8 +726,7 @@ func _show_existing_data_folder_choice(path: String) -> void: dialog.add_button("replace selected data", false, "replace") dialog.confirmed.connect(func() -> void: if _data_root.use_existing_root(path): - _feedback.text = "data folder changed. straywild will close safely." - get_tree().call_deferred("quit") + _complete_data_root_change("data folder changed.") else: _feedback.text = _data_root.error_message dialog.queue_free() @@ -733,7 +741,7 @@ func _show_existing_data_folder_choice(path: String) -> void: result.get("message", "could not replace selected data.") ) if bool(result.get("ok", false)): - get_tree().call_deferred("quit") + _complete_data_root_change(_feedback.text.to_lower()) dialog.queue_free() ) _interface_fonts.apply_utility_theme(dialog) @@ -744,6 +752,12 @@ func _show_existing_data_folder_choice(path: String) -> void: ) +func _complete_data_root_change(message: String) -> void: + _feedback.text = message + _refresh_data_page() + data_root_changed.emit() + + func _choose_identity_export(identity_type: String) -> void: if not _identity_operation_allowed(): return diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 3055e15..0a80f63 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -243,6 +243,11 @@ func reopen_to_menu() -> void: _set_title_bubbles_interactive(true) +func refresh_after_data_root_change() -> void: + _save_slots_page.refresh_page() + _refresh_save_inspection() + + func open_join_game_page(endpoint: String = "") -> void: _cancel_title_entry_transition() _awaiting_start_input = false