diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/BSON Examples/dunk.gd b/BSON Examples/dunk.gd new file mode 100644 index 0000000..4323709 --- /dev/null +++ b/BSON Examples/dunk.gd @@ -0,0 +1,34 @@ +extends Control + + +# These are set in dunk.tscn in the root node properties. +@export var JSONEditor: CodeEdit +@export var OutputLabel: Label +@export var CopyPopup: PopupPanel + + +func _on_button_pressed() -> void: + var n_json = JSON.parse_string(JSONEditor.text) # Could be Dictionary or null + if !n_json: return # JSON parse failed + + var b_json := BSON.to_bson(n_json) + var d_json := BSON.from_bson(b_json) + + OutputLabel.text = ("SERIALIZED BSON:\n" + + str(b_json) + + "\nDESERIALIZED JSON:\n" + + str(d_json)) + +func _on_copy_pressed() -> void: + DisplayServer.clipboard_set(OutputLabel.text) + CopyPopup.show() + + var timer := Timer.new() + timer.autostart = true + timer.one_shot = true + timer.wait_time = 1.5 + add_child(timer) + + await timer.timeout + timer.queue_free() + CopyPopup.hide() diff --git a/BSON Examples/dunk.tscn b/BSON Examples/dunk.tscn new file mode 100644 index 0000000..3531d6b --- /dev/null +++ b/BSON Examples/dunk.tscn @@ -0,0 +1,131 @@ +[gd_scene load_steps=2 format=3 uid="uid://ckdfb5ggwslbk"] + +[ext_resource type="Script" path="res://BSON Examples/dunk.gd" id="1_p38ab"] + +[node name="Dunk" type="Control" node_paths=PackedStringArray("JSONEditor", "OutputLabel", "CopyPopup")] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_p38ab") +JSONEditor = NodePath("Panel/JSONEdit") +OutputLabel = NodePath("Panel/Scroll/Output") +CopyPopup = NodePath("CopyPopup") + +[node name="Panel" type="Panel" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 119.0 +offset_top = 67.0 +offset_right = -119.0 +offset_bottom = -67.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="JSONEdit" type="CodeEdit" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 86.0 +offset_top = 14.0 +offset_right = -86.0 +offset_bottom = -277.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "{ + \"username\": \"johndoe\", + \"firstname\": \"John\", + \"lastname\": \"Doe\", + \"email\": { + \"adress\": \"john-doe@example.com\", + \"verified\": true + }, + \"age\": 32, + \"phone\": { + \"number\": \"0123456789\", + \"verified\": false + }, + \"assets\": [ + \"foo\", + \"bar\", + 42, + true, + false, + 3.14159265, + \"baz\" + ] +}" +middle_mouse_paste_enabled = false +minimap_draw = true +gutters_draw_line_numbers = true +indent_automatic = true +auto_brace_completion_enabled = true +auto_brace_completion_highlight_matching = true + +[node name="Dunk" type="Button" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 364.0 +offset_top = 243.0 +offset_right = -364.0 +offset_bottom = -228.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "Run" + +[node name="Copy" type="Button" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 364.0 +offset_top = 291.0 +offset_right = -364.0 +offset_bottom = -180.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "Copy Output +" + +[node name="Scroll" type="ScrollContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 83.0 +offset_top = 346.0 +offset_right = -83.0 +offset_bottom = -11.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Output" type="Label" parent="Panel/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "This example will \"dunk\" your JSON into BSON and then convert it back to JSON. +Due to a quirk in the way Godot parses JSON, all integers will be parsed as floats. +This may be a bug in Godot." +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="CopyPopup" type="PopupPanel" parent="."] +transparent_bg = true +position = Vector2i(492, 586) +size = Vector2i(164, 31) + +[node name="Label" type="Label" parent="CopyPopup"] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 160.0 +offset_bottom = 27.0 +text = "Copied to clipboard!" + +[connection signal="pressed" from="Panel/Dunk" to="." method="_on_button_pressed"] +[connection signal="pressed" from="Panel/Copy" to="." method="_on_copy_pressed"] diff --git a/README.md b/README.md index bb1b64a..8f771f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ -# godot-bson +# BSON for the Godot Engine +This is a simple BSON serializer and deserializer written in GDScript that is originally designed to be compatible with [JSON for Modern C++](https://json.nlohmann.me/)'s BSON components, but it can be used with any other BSON tool. -A BSON serializer/deserializer for the Godot Engine \ No newline at end of file +From [bsonspec.org](https://bsonspec.org/): +BSON, short for Bin­ary [JSON](http://json.org), is a bin­ary-en­coded seri­al­iz­a­tion of JSON-like doc­u­ments. Like JSON, BSON sup­ports the em­bed­ding of doc­u­ments and ar­rays with­in oth­er doc­u­ments and ar­rays. + +This plugin is useful for server/client communication, interacting with MongoDB, reducing JSON file sizes, etc. + +After enabling this plugin in your Godot project settings, you can access the BSON object with: +``` +BSON.to_bson(Dictionary) +``` +and +``` +BSON.from_bson(PackedByteArray) +``` +You can also test out this plugin with `/BSON Examples/dunk.tscn`. This example will take your JSON, serialize it to BSON, then deserialize it back to JSON. diff --git a/addons/bson/bson.gd b/addons/bson/bson.gd new file mode 100644 index 0000000..47ba328 --- /dev/null +++ b/addons/bson/bson.gd @@ -0,0 +1,269 @@ +# COPYRIGHT 2025 Colormatic Studios and contributors. +# This file is the BSON serializer for the Godot Engine, +# published under the MIT license. https://opensource.org/license/MIT + +class_name BSON + + +static func to_bson(data: Dictionary) -> PackedByteArray: + var document := dictionary_to_bytes(data) + document.append(0x00) + var buffer := PackedByteArray() + buffer.append_array(int32_to_bytes(document.size() + 4)) + buffer.append_array(document) + return buffer + +static func get_byte_type(value: Variant) -> int: + match typeof(value): + TYPE_STRING: + return 0x02 + TYPE_INT: + if abs(value as int) < 2147483647: # 32 bit signed integer limit + return 0x10 + else: + return 0x12 + TYPE_FLOAT: + return 0x01 + TYPE_ARRAY: + return 0x04 + TYPE_DICTIONARY: + return 0x03 + TYPE_BOOL: + return 0x08 + _: + push_error("BSON serialization error: Unsupported type: ", typeof(value)) + return 0x00 + +static func int16_to_bytes(value: int) -> PackedByteArray: + var buffer := PackedByteArray() + buffer.resize(2) + buffer.encode_s16(0, value) + return buffer + +static func int32_to_bytes(value: int) -> PackedByteArray: + var buffer := PackedByteArray() + buffer.resize(4) + buffer.encode_s32(0, value) + return buffer + +static func int64_to_bytes(value: int) -> PackedByteArray: + var buffer := PackedByteArray() + buffer.resize(8) + buffer.encode_s64(0, value) + return buffer + +static func float_to_bytes(value: float) -> PackedByteArray: + var buffer := PackedByteArray() + buffer.resize(4) + buffer.encode_float(0, value) + return buffer + +static func double_to_bytes(value: float) -> PackedByteArray: + var buffer := PackedByteArray() + buffer.resize(8) + buffer.encode_double(0, value) + return buffer + +static func dictionary_to_bytes(dict: Dictionary) -> PackedByteArray: + var buffer := PackedByteArray() + + for key: String in dict.keys(): + buffer.append(get_byte_type(dict[key])) + var key_string_bytes := key.to_utf8_buffer() + buffer.append_array(key_string_bytes) + buffer.append(0x00) + buffer.append_array(serialize_variant(dict[key])) + + return buffer + +static func array_to_bytes(array: Array[Variant]) -> PackedByteArray: + var buffer := PackedByteArray() + + for index: int in range(array.size()): + buffer.append(get_byte_type(array[index])) + # For whatever reason, BSON wants array indexes to be strings. This makes no sense. + var s_index := str(index) + buffer.append_array(s_index.to_utf8_buffer()) + buffer.append(0x00) + + buffer.append_array(serialize_variant(array[index])) + + return buffer + +static func serialize_variant(data: Variant) -> PackedByteArray: + var buffer := PackedByteArray() + match typeof(data): + TYPE_DICTIONARY: + var document := dictionary_to_bytes(data as Dictionary) + buffer.append_array(int32_to_bytes(document.size())) + buffer.append_array(document) + buffer.append(0x00) + TYPE_ARRAY: + var b_array := array_to_bytes(data as Array[Variant]) + buffer.append_array(int32_to_bytes(b_array.size())) + buffer.append_array(b_array) + buffer.append(0x00) + TYPE_STRING: + var str_as_bytes := (data as String).to_utf8_buffer() + buffer.append_array(int32_to_bytes(str_as_bytes.size() + 1)) + buffer.append_array(str_as_bytes) + buffer.append(0x00) + TYPE_INT: + if abs(data as int) < 2147483647: # 32 bit signed integer limit + buffer.append_array(int32_to_bytes(data as int)) + else: + buffer.append_array(int64_to_bytes(data as int)) + TYPE_FLOAT: + buffer.append_array(double_to_bytes(data as float)) + TYPE_BOOL: + buffer.append((data as bool) if 0x01 else 0x00) + _: + buffer.append(0x00) + + return buffer + + +static func from_bson(data: PackedByteArray) -> Dictionary: + return Deserializer.new(data).read_dictionary() + + +class Deserializer: + var buffer: PackedByteArray + var read_position := 0 + + func _init(buffer: PackedByteArray): + self.buffer = buffer + + func get_int8() -> int: + var value := buffer[read_position] + read_position += 1 + return value + + func get_int16() -> int: + var value := buffer.decode_s16(read_position) + read_position += 2 + return value + + func get_int32() -> int: + var value := buffer.decode_s32(read_position) + read_position += 4 + return value + + func get_int64() -> int: + var value := buffer.decode_s64(read_position) + read_position += 8 + return value + + func get_float() -> float: + var value := buffer.decode_float(read_position) + read_position += 4 + return value + + func get_double() -> float: + var value := buffer.decode_double(read_position) + read_position += 8 + return value + + func get_string() -> String: + var expected_size = get_int32() + var s_value: String + var iter := 0 + while true: + iter += 1 + var b_char := get_int8() + if b_char == 0x00: break + s_value += char(b_char) + if expected_size != iter: # Check if the string is terminated with 0x00 + push_error("BSON deserialization error: String was the wrong size." + + " Position: " + + str(read_position - iter) + + ", stated size: " + + str(expected_size) + + ", actual size: " + + str(iter)) + return s_value + + func get_bool() -> bool: + return (get_int8() == 1) + + func read_dictionary() -> Dictionary: + var object = {} + + var expected_size := get_int32() + + var iter := 0 + while true: + iter += 1 + var type := get_int8() + if type == 0x00: break + + var key := "" + + while true: + var k_char := get_int8() + if k_char == 0x00: break + key += char(k_char) + + match type: + 0x02: object[key] = get_string() + 0x10: object[key] = get_int32() + 0x12: object[key] = get_int64() + 0x01: object[key] = get_double() + 0x08: object[key] = get_bool() + 0x04: object[key] = read_array() + 0x03: object[key] = read_dictionary() + _: + push_error("BSON deserialization error: Unsupported type " + + str(type) + + " at byte " + + str(read_position - 1)) + + if iter > expected_size: + push_warning("BSON deserialization warning: Dictionary is the wrong length." + + " Expected dictionary length: " + + str(expected_size) + + ", Actual dictionary length: " + + str(iter)) + return object + + func read_array() -> Array: + var array: Array + + var expected_size := get_int32() + + var iter := 0 + while true: + iter += 1 + var type := get_int8() + if type == 0x00: break + + var key: String + + while true: + var k_char := get_int8() + if k_char == 0x00: break + key += char(k_char) + + # IMPORTANT: Since the array is being deserialized sequentially, we can + # use the Array.append() function. It would be better to set the index + # directly, but that is not possible. (It *could* cause null holes) + # The BSON specification unfortunately allows for null holes, but + # this deserializer will just remove any gaps. This could cause an + # index desynchronization between two programs communicating with BSON, + # but that means the other program has a buggy serializer. + match type: + 0x02: array.append(get_string()) + 0x10: array.append(get_int32()) + 0x12: array.append(get_int64()) + 0x01: array.append(get_double()) + 0x08: array.append(get_bool()) + 0x04: array.append(read_array()) + 0x03: array.append(read_dictionary()) + _: push_error("BSON deserialization error: Unsupported type: " + str(type)) + if iter > expected_size: + push_warning("BSON deserialization warning: Array is the wrong length." + + " Expected array length: " + + str(expected_size) + + ", Actual array length: " + + str(iter)) + return array diff --git a/addons/bson/plugin.cfg b/addons/bson/plugin.cfg new file mode 100644 index 0000000..ae2b3c5 --- /dev/null +++ b/addons/bson/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="BSON" +description="A BSON class to serialize and deserialize BSON in GDScript." +author="Colormatic Studios" +version="" +script="plugin.gd" diff --git a/addons/bson/plugin.gd b/addons/bson/plugin.gd new file mode 100644 index 0000000..b7c36d0 --- /dev/null +++ b/addons/bson/plugin.gd @@ -0,0 +1,20 @@ +@tool +extends EditorPlugin + + +const AUTOLOAD_NAME = "BSON" + + +#func _enter_tree() -> void: + # Initialization of the plugin goes here. + #pass + +#func _exit_tree() -> void: + # Clean-up of the plugin goes here. + #pass + +func _enable_plugin() -> void: + add_autoload_singleton(AUTOLOAD_NAME, "res://addons/bson/bson.gd") + +func _disable_plugin() -> void: + remove_autoload_singleton(AUTOLOAD_NAME) diff --git a/icon-full.svg b/icon-full.svg new file mode 100644 index 0000000..b8e6a18 --- /dev/null +++ b/icon-full.svg @@ -0,0 +1,89 @@ + + + + diff --git a/icon-full.svg.import b/icon-full.svg.import new file mode 100644 index 0000000..66378a2 --- /dev/null +++ b/icon-full.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6htrl16c0eay" +path="res://.godot/imported/icon-full.svg-e72bf6bf218ce1384c33716fd0ad1d25.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon-full.svg" +dest_files=["res://.godot/imported/icon-full.svg-e72bf6bf218ce1384c33716fd0ad1d25.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..7382e60 --- /dev/null +++ b/icon.svg @@ -0,0 +1,78 @@ + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..45d6014 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0iy4oa1unjrt" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..1ffbe97 --- /dev/null +++ b/project.godot @@ -0,0 +1,20 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="BSON for Godot" +run/main_scene="res://BSON Examples/dunk.tscn" +config/features=PackedStringArray("4.3", "Forward Plus") +config/icon="res://icon.svg" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/bson/plugin.cfg")