diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7d290c..ebda1b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,21 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- Functions such as `print`, `warn`, ... now respect `__tostring` metamethods
+- Functions such as `print`, `warn`, ... now respect `__tostring` metamethods.
### Fixed
-- Fixed issues with CFrame math operations
+- Fixed access of roblox instance properties such as `Workspace.Terrain`, `game.Workspace` that are actually links to child instances.
+ These properties are always guaranteed to exist, and they are not always properly set, meaning they must be found through an internal lookup.
+- Fixed issues with CFrame math operations.
## `0.6.4` - March 26th, 2023
### Fixed
-- Fixed instances with attributes not saving if they contain integer attributes
-- Fixed attributes not being set properly if the instance has an empty attributes property
-- Fixed error messages for reading & writing roblox files not containing the full error message
-- Fixed crash when trying to access an instance reference property that points to a destroyed instance
-- Fixed crash when trying to save instances that contain unsupported attribute types
+- Fixed instances with attributes not saving if they contain integer attributes.
+- Fixed attributes not being set properly if the instance has an empty attributes property.
+- Fixed error messages for reading & writing roblox files not containing the full error message.
+- Fixed crash when trying to access an instance reference property that points to a destroyed instance.
+- Fixed crash when trying to save instances that contain unsupported attribute types.
## `0.6.3` - March 26th, 2023
@@ -37,9 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-- Fixed accessing a destroyed instance printing an error message even if placed inside a pcall
+- Fixed accessing a destroyed instance printing an error message even if placed inside a pcall.
- Fixed cloned instances not having correct instance reference properties set (`ObjectValue.Value`, `Motor6D.Part0`, ...)
-- Fixed `Instance::GetDescendants` returning the same thing as `Instance::GetChildren` (oops)
+- Fixed `Instance::GetDescendants` returning the same thing as `Instance::GetChildren`.
## `0.6.2` - March 25th, 2023
@@ -47,22 +49,22 @@ This release adds some new features and fixes for the `roblox` built-in.
### Added
-- Added `GetAttribute`, `GetAttributes` and `SetAttribute` methods for instances
-- Added support for getting & setting properties that are instance references
+- Added `GetAttribute`, `GetAttributes` and `SetAttribute` methods for instances.
+- Added support for getting & setting properties that are instance references.
### Changed
-- Improved handling of optional property types such as optional cframes & default physical properties
+- Improved handling of optional property types such as optional cframes & default physical properties.
### Fixed
-- Fixed handling of instance properties that are serialized as binary strings
+- Fixed handling of instance properties that are serialized as binary strings.
## `0.6.1` - March 22nd, 2023
### Fixed
-- Fixed `writePlaceFile` and `writeModelFile` in the new `roblox` built-in making mysterious "ROOT" instances
+- Fixed `writePlaceFile` and `writeModelFile` in the new `roblox` built-in making mysterious "ROOT" instances.
## `0.6.0` - March 22nd, 2023
diff --git a/packages/lib-roblox/src/instance/data_model.rs b/packages/lib-roblox/src/instance/data_model.rs
index 672d48f..040b455 100644
--- a/packages/lib-roblox/src/instance/data_model.rs
+++ b/packages/lib-roblox/src/instance/data_model.rs
@@ -1,16 +1,37 @@
use mlua::prelude::*;
-use crate::shared::{classes::add_class_restricted_method, instance::class_is_a_service};
+use crate::shared::{
+ classes::{
+ add_class_restricted_getter, add_class_restricted_method,
+ get_or_create_property_ref_instance,
+ },
+ instance::class_is_a_service,
+};
use super::Instance;
pub const CLASS_NAME: &str = "DataModel";
+pub fn add_fields<'lua, M: LuaUserDataFields<'lua, Instance>>(m: &mut M) {
+ add_class_restricted_getter(m, CLASS_NAME, "Workspace", data_model_get_workspace);
+}
+
pub fn add_methods<'lua, M: LuaUserDataMethods<'lua, Instance>>(m: &mut M) {
add_class_restricted_method(m, CLASS_NAME, "GetService", data_model_get_service);
add_class_restricted_method(m, CLASS_NAME, "FindService", data_model_find_service);
}
+/**
+ Get the workspace parented under this datamodel, or create it if it doesn't exist.
+
+ ### See Also
+ * [`Terrain`](https://create.roblox.com/docs/reference/engine/classes/Workspace#Terrain)
+ on the Roblox Developer Hub
+*/
+fn data_model_get_workspace(_: &Lua, this: &Instance) -> LuaResult {
+ get_or_create_property_ref_instance(this, "Workspace", "Workspace")
+}
+
/**
Gets or creates a service for this DataModel.
diff --git a/packages/lib-roblox/src/instance/mod.rs b/packages/lib-roblox/src/instance/mod.rs
index dbd58d1..ab96b99 100644
--- a/packages/lib-roblox/src/instance/mod.rs
+++ b/packages/lib-roblox/src/instance/mod.rs
@@ -25,6 +25,7 @@ use crate::{
pub(crate) mod collection_service;
pub(crate) mod data_model;
+pub(crate) mod workspace;
const PROPERTY_NAME_ATTRIBUTES: &str = "Attributes";
const PROPERTY_NAME_TAGS: &str = "Tags";
@@ -34,8 +35,8 @@ static INTERNAL_DOM: Lazy> =
#[derive(Debug, Clone)]
pub struct Instance {
- dom_ref: DomRef,
- class_name: String,
+ pub(crate) dom_ref: DomRef,
+ pub(crate) class_name: String,
}
impl Instance {
@@ -45,7 +46,7 @@ impl Instance {
Panics if the instance does not exist in the internal dom,
or if the given dom object ref points to the dom root.
*/
- fn new(dom_ref: DomRef) -> Self {
+ pub(crate) fn new(dom_ref: DomRef) -> Self {
let dom = INTERNAL_DOM
.try_read()
.expect("Failed to get read access to document");
@@ -93,7 +94,7 @@ impl Instance {
An orphaned instance is an instance at the root of a weak dom.
*/
- fn new_orphaned(class_name: impl AsRef) -> Self {
+ pub(crate) fn new_orphaned(class_name: impl AsRef) -> Self {
let mut dom = INTERNAL_DOM
.try_write()
.expect("Failed to get write access to document");
@@ -842,6 +843,13 @@ impl Instance {
}
impl LuaUserData for Instance {
+ fn add_fields<'lua, F: LuaUserDataFields<'lua, Self>>(fields: &mut F) {
+ // Here we add inheritance-like behavior for instances by creating
+ // fields that are restricted to specific classnames / base classes
+ data_model::add_fields(fields);
+ workspace::add_fields(fields);
+ }
+
fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) {
methods.add_meta_method(LuaMetaMethod::ToString, |lua, this, ()| {
this.ensure_not_destroyed()?;
diff --git a/packages/lib-roblox/src/instance/workspace.rs b/packages/lib-roblox/src/instance/workspace.rs
new file mode 100644
index 0000000..f1ae4b8
--- /dev/null
+++ b/packages/lib-roblox/src/instance/workspace.rs
@@ -0,0 +1,34 @@
+use mlua::prelude::*;
+
+use crate::shared::classes::{add_class_restricted_getter, get_or_create_property_ref_instance};
+
+use super::Instance;
+
+pub const CLASS_NAME: &str = "Workspace";
+
+pub fn add_fields<'lua, M: LuaUserDataFields<'lua, Instance>>(m: &mut M) {
+ add_class_restricted_getter(m, CLASS_NAME, "Terrain", workspace_get_terrain);
+ add_class_restricted_getter(m, CLASS_NAME, "CurrentCamera", workspace_get_camera);
+}
+
+/**
+ Get the terrain parented under this workspace, or create it if it doesn't exist.
+
+ ### See Also
+ * [`Terrain`](https://create.roblox.com/docs/reference/engine/classes/Workspace#Terrain)
+ on the Roblox Developer Hub
+*/
+fn workspace_get_terrain(_: &Lua, this: &Instance) -> LuaResult {
+ get_or_create_property_ref_instance(this, "Terrain", "Terrain")
+}
+
+/**
+ Get the camera parented under this workspace, or create it if it doesn't exist.
+
+ ### See Also
+ * [`CurrentCamera`](https://create.roblox.com/docs/reference/engine/classes/Workspace#CurrentCamera)
+ on the Roblox Developer Hub
+*/
+fn workspace_get_camera(_: &Lua, this: &Instance) -> LuaResult {
+ get_or_create_property_ref_instance(this, "CurrentCamera", "Camera")
+}
diff --git a/packages/lib-roblox/src/shared/classes.rs b/packages/lib-roblox/src/shared/classes.rs
index bb0fb98..9a2a683 100644
--- a/packages/lib-roblox/src/shared/classes.rs
+++ b/packages/lib-roblox/src/shared/classes.rs
@@ -1,9 +1,54 @@
use mlua::prelude::*;
+use rbx_dom_weak::types::Variant as DomValue;
+
use crate::instance::Instance;
use super::instance::class_is_a;
+pub(crate) fn add_class_restricted_getter<'lua, F: LuaUserDataFields<'lua, Instance>, R, G>(
+ fields: &mut F,
+ class_name: &'static str,
+ field_name: &'static str,
+ field_getter: G,
+) where
+ R: ToLua<'lua>,
+ G: 'static + Fn(&'lua Lua, &Instance) -> LuaResult,
+{
+ fields.add_field_method_get(field_name, move |lua, this| {
+ if class_is_a(this.get_class_name(), class_name).unwrap_or(false) {
+ field_getter(lua, this)
+ } else {
+ Err(LuaError::RuntimeError(format!(
+ "{} is not a valid member of {}",
+ field_name, class_name
+ )))
+ }
+ });
+}
+
+#[allow(dead_code)]
+pub(crate) fn add_class_restricted_setter<'lua, F: LuaUserDataFields<'lua, Instance>, A, G>(
+ fields: &mut F,
+ class_name: &'static str,
+ field_name: &'static str,
+ field_getter: G,
+) where
+ A: FromLua<'lua>,
+ G: 'static + Fn(&'lua Lua, &Instance, A) -> LuaResult<()>,
+{
+ fields.add_field_method_set(field_name, move |lua, this, value| {
+ if class_is_a(this.get_class_name(), class_name).unwrap_or(false) {
+ field_getter(lua, this, value)
+ } else {
+ Err(LuaError::RuntimeError(format!(
+ "{} is not a valid member of {}",
+ field_name, class_name
+ )))
+ }
+ });
+}
+
pub(crate) fn add_class_restricted_method<'lua, M: LuaUserDataMethods<'lua, Instance>, A, R, F>(
methods: &mut M,
class_name: &'static str,
@@ -54,3 +99,36 @@ pub(crate) fn add_class_restricted_method_mut<
}
});
}
+
+/**
+ Gets or creates the instance child with the given reference prop name and class name.
+
+ Note that the class name here must be an exact match, it is not checked using IsA.
+
+ The instance may be in one of several states but this function will guarantee that the
+ property reference is correct and that the instance exists after it has been called:
+
+ 1. Instance exists as property ref - just return it
+ 2. Instance exists under workspace but not as a property ref - save it and return it
+ 3. Instance does not exist - create it, save it, then return it
+*/
+pub(crate) fn get_or_create_property_ref_instance(
+ this: &Instance,
+ prop_name: &'static str,
+ class_name: &'static str,
+) -> LuaResult {
+ if let Some(DomValue::Ref(inst_ref)) = this.get_property(prop_name) {
+ if let Some(inst) = Instance::new_opt(inst_ref) {
+ return Ok(inst);
+ }
+ }
+ if let Some(inst) = this.find_child(|child| child.class == class_name) {
+ this.set_property(prop_name, DomValue::Ref(inst.dom_ref));
+ Ok(inst)
+ } else {
+ let inst = Instance::new_orphaned(class_name);
+ inst.set_parent(Some(this.clone()));
+ this.set_property(prop_name, DomValue::Ref(inst.dom_ref));
+ Ok(inst)
+ }
+}
diff --git a/packages/lib/src/tests.rs b/packages/lib/src/tests.rs
index 760ae49..3a24bc3 100644
--- a/packages/lib/src/tests.rs
+++ b/packages/lib/src/tests.rs
@@ -123,11 +123,13 @@ create_tests! {
roblox_files_write_place: "roblox/files/writePlaceFile",
roblox_instance_attributes: "roblox/instance/attributes",
- roblox_instance_datamodel: "roblox/instance/datamodel",
roblox_instance_new: "roblox/instance/new",
roblox_instance_properties: "roblox/instance/properties",
roblox_instance_tags: "roblox/instance/tags",
+ roblox_instance_classes_data_model: "roblox/instance/classes/DataModel",
+ roblox_instance_classes_workspace: "roblox/instance/classes/Workspace",
+
roblox_instance_methods_clear_all_children: "roblox/instance/methods/ClearAllChildren",
roblox_instance_methods_clone: "roblox/instance/methods/Clone",
roblox_instance_methods_destroy: "roblox/instance/methods/Destroy",
diff --git a/tests/roblox/instance/datamodel.luau b/tests/roblox/instance/classes/DataModel.luau
similarity index 51%
rename from tests/roblox/instance/datamodel.luau
rename to tests/roblox/instance/classes/DataModel.luau
index f75e95b..5567c54 100644
--- a/tests/roblox/instance/datamodel.luau
+++ b/tests/roblox/instance/classes/DataModel.luau
@@ -3,20 +3,26 @@ local Instance = roblox.Instance
local game = Instance.new("DataModel")
-assert(game:FindService("Workspace") == nil)
-assert(game:GetService("Workspace") ~= nil)
-assert(game:FindService("Workspace") ~= nil)
+-- Workspace should always exist as a "Workspace" property, or be created when accessed
+
+assert(game.Workspace ~= nil)
+assert(game.Workspace:IsA("Workspace"))
+assert(game.Workspace == game:FindFirstChildOfClass("Workspace"))
+
+-- GetService and FindService should work, GetService should create services that don't exist
assert(game:FindService("CSGDictionaryService") == nil)
assert(game:GetService("CSGDictionaryService") ~= nil)
assert(game:FindService("CSGDictionaryService") ~= nil)
+-- Service names should be strict and not allow weird characters or substrings
+
assert(not pcall(function()
game:GetService("wrorokspacey")
end))
assert(not pcall(function()
- game:GetService("work-space")
+ game:GetService("Work-space")
end))
assert(not pcall(function()
diff --git a/tests/roblox/instance/classes/Workspace.luau b/tests/roblox/instance/classes/Workspace.luau
new file mode 100644
index 0000000..523a1fa
--- /dev/null
+++ b/tests/roblox/instance/classes/Workspace.luau
@@ -0,0 +1,17 @@
+local roblox = require("@lune/roblox") :: any
+local Instance = roblox.Instance
+
+local game = Instance.new("DataModel")
+local workspace = game:GetService("Workspace")
+
+-- Terrain should always exist as a "Terrain" property, or be created when accessed
+
+assert(workspace.Terrain ~= nil)
+assert(workspace.Terrain:IsA("Terrain"))
+assert(workspace.Terrain == workspace:FindFirstChildOfClass("Terrain"))
+
+-- Camera should always exist as a "CurrentCamera" property, or be created when accessed
+
+assert(workspace.CurrentCamera ~= nil)
+assert(workspace.CurrentCamera:IsA("Camera"))
+assert(workspace.CurrentCamera == workspace:FindFirstChildOfClass("Camera"))