scripthookvdotnet / scripthookvdotnet

An ASI plugin for Grand Theft Auto V, which allows running scripts written in any .NET language on the .NET Framework runtime in-game.
https://scripthookvdotnet.github.io
zlib License
1.18k stars 627 forks source link

Ideas for v4 API #1227

Open kagikn opened 1 year ago

kagikn commented 1 year ago

Since v3.7.0 is coming soon, which contains various performance improvements, compatibility fixes for earlier game versions and a lot of features, it's definitely a good idea we should start thinking how we should design the v4 API. We could provide more organized/polished APIs before I start thinking about how to create another runtime variant of SHVDN built against .NET 7+ with the use of features only available in .NET Core environment, such as Span.

More ideas will be suggested periodically, so it would be great if you could leave your own idea/feedback below.

Separate AppDomain per script

Since you can share variables between scripts without using dedicated ways in v2 and v3, we can't use separate AppDomains for scripts built agains v2 or v3 in favor of compatibility (See #1162 for the details).

Struct InputArgument

One of our biggest design mistakes IMO. No need to use class for a single 8 byte value that can be exposed in the public without having too much of issues. For OutputArgument, we could define a implicit operator to convert to InputArgument. We don't want to push the GC pressure for native calls in future APIs.

Weapon Classes Restructure

We could make brand new weapon or ped inventory classes such as PedInventory, PedWeaponInventory, PedWeaponInventoryItem, PedAmmoInventory, and PedAmmoInventoryItem. There is CWeapon, which manages weapon states such as the current ammo count in clip (not a subclass of CObject btw), so we should not reserve Weapon class for the class for weapon inventory item of a ped IMO.

Remove Auto Model Loading from Methods For Entity Creation

Looks like the not a few script developers casually create entities just with World.Create* (not applies to CreateRandom variants as they use one of loaded ambient models), while you cannot control how you load models by those methods and it is not guaranteed you can create multiple entities with different models at the same frame. Not a few script developers don't bother to mark models they requested as no longer needed so the game can free the resource for the models, either.

Script developers will care about model resource handling as you would need to in ysc or C++ scripts in the v4 API. More code to write in your scripts, but you may fail to create multiple entities with different models otherwise. SHVDN will return null if fails to create due to the model absent (CREATE_* natives won't crash even if the model is not loaded like equivalent opcodes do crash the game in that case in 3D Era games, will return zero instead in that case).

Separate TextStyle class

We should separate text drawing or measureing class and text style class just like how RAGENativeUI handles the current text style. Separating them would increase performance as we can avoid redundant native calls for applying text style (you can't avoid END_TEXT_COMMAND_DISPLAY_TEXT setting the font style values to default ones however).

Dedicated Static Control Class

Simple, the Game class is kinda bloated for control methods. Since the exe has the dedicated class CControl, making a dedicated class for Control would make sense.

Await support for custom Task-ish class (but not for built-in Task in favor of performance)

It would be great if you can use the await syntax, correct? Looks like we shouldn't use the classic Tasks in SHVDN built against .NET Framework, because Task/Task<T> that are used for the default .NET scheduler suffers way more overhead than in .NET Core/NET 5+. FiveM suffered the slow Tasks and they are introducing the custom Task-ish classes Coroutine/Coroutine\<T>. Looks like we should not introduce our custom task scheduler for built-in Task in SHVDN that runs on .NET Framework, either. Maybe it won't suffer too much of overhead if we use our custom task scheduler for built-in Task in a new SHVDN equivalent runtime built against .NET 7+ though.

For your information, the built-in Tasks allocates less resources such as ExecutionContext in .NET Core 2.1, and there are more improvements for async jobs in .NET 6.

Don't use more than 1 thread for all .NET scripts (if possible)

I believe there's no need to use more than one dedicated thread for .NET scripts as long as we can stop executing them when an unhandled exception is thrown or they takes too much time in one loop without a debugger attached. We might need one dedicated thread for more stack space, though. If we use a task scheduler (that execute in a single thread) to process scripts, we'll end up in losing the ability to abort scripts that takes too long time in one loop without letting them execute one loop, but I wouldn't care that ability too much since The docs of .NET doesn't recommend to use Thread.Abort very much and it's not supported in .NET 5+. RAGE Plugin Hook doesn't abort plugin execution during a loop for long execution time and instead it terminates the plugin after the loop ends FYI.

The compatibility may break too much if we reduce the number threads to one, but we could do this without worrying about the compatibility for the v4 API before the first official version with v4 API is published.

Misc

Tasks

kagikn commented 1 year ago

Change the plan in our task scheduler. I realized a pitfall that comes with big performance overhead in .NET Framework. FiveM Collective realized the built-in Task classes are too slow to casually use in the Mono that FiveM uses. Peek the implementation of FiveM's mscorlib.dll to confirm, it has ExecutionContext whose implementation is largely shared with the mscorlib.dll of .NET Framework 4.8. That would mean the Task classes in Mono that FiveM uses suffers as much overhead as in .NET Framework 4.8.

BoBoBaSs84 commented 1 year ago

Hi,

It would be cool to have a "native" support for abstractions. So that you can develop against interfaces instead against the concrete implementations. This would make things more testable. You know something like:

using GTA;

public interface ITimeProvider
{
    /// <summary>
    /// Gets or sets the current date and time in the <see cref="World"/>.
    /// </summary>
    DateTime Now { get; set; }

    /// <summary>
    /// Gets or sets the current time of day in the <see cref="World"/>.
    /// </summary>
    TimeSpan TimeOfDay { get; set; }
}
using GTA;

internal sealed class TimeProvider : ITimeProvider
{
    public DateTime Now
    {
        get => World.CurrentDate;
        set => World.CurrentDate = value;
    }

    public TimeSpan TimeOfDay
    {
        get => World.CurrentTimeOfDay;
        set => World.CurrentTimeOfDay = value;
    }
}

With something like it you could use a test implementation to check for logic components that require the current in game date time or use a mock to test things.

Mock<ITimeProvider> mock = new();
mock.Setup(x => x.Now).Returns(DateTime.MinValue.AddYears(100));
mock.Setup(x => x.TimeOfDay).Returns(DateTime.MinValue.AddYears(100).TimeOfDay);
kagikn commented 1 year ago

It would be great we could test some logics more easily, yes, but I'm also concerned about how much care we should implement our interfaces with. While coding against interfaces enables to use a mock, we should define interfaces with care since we can't change anything of existing interfaces without breaking changes in .NET Framework. You can't use default implementation in .NET Framework due to the lack of the support by runtime, either. We can circumvent the issue by adding a new interface or extension methods, however.

Since the game internally uses static variables for clock stuff in both 3D Era games and HD Era games (thus this applies to GTA V), we could provide a clock interface rather than a interface that only have Now and TimeOfDay? Oh, there's one important pitfall, every month has 31 days, even February. Maybe game months are supposed to be have variable days just like our date time system as you can find the 4-byte array of predefined days (non-leap year variant) in the exe and the exe also has a function that considers leap year. But in reality, every month has 31 days in the game. DateTime in .NET can't take 31 days for every month. These 2 factors make us has to provide individual properties for the game date time data to represent all the range of game date time. cbc5a50 caps month range, but SHVDN hasn't provided a proper way to read/write month with all range support as of v3.6.0.

For those wondering a init function for CClock (name hash used until b2845: 0x54F8BB2C) to pass some function (named gameSkeleton or something?), you can find it with F3 48 0F 2A C7 8D 0C C8 6B C9 3C. The formula to check if the passed year is a leap year in the exe: !(year % 4) && year != 100 * (year / 100) || year == 400 * (year / 400)

BoBoBaSs84 commented 1 year ago

Hey cool and thanks for the quick reply. I can't say much about second and third part, that's simply not my profession. Maybe an abstraction layer doesn't belong in the API 4.x itself maybe it's more of an extension, another nuget that encapsulates the accesses of the static methods in some kind of providers. You have access via the extension to an interface, let's call it IWorldProvider, which defines all methods of the static world class. The concrete WorldProvider class then only provides an instantiable access to the static class, simply a wrapper.

In the actual scripts you access these interfaces instead of the static classes and can make your scripts testable to a certain extent. Maybe the previous example was not so good, you really blew up at the topic "time in games in C++" ;)

The only way for me so far was to encapsulate everything manually to make it testable.

using GTA;
using GTA.Math;

namespace LSDW.Abstractions.Domain.Providers;

/// <summary>
/// The player provider interface.
/// </summary>
public interface IPlayerProvider
{
    /// <summary>
    /// Gets the <see cref="Ped"/> this <see cref="Player"/> is controlling.
    /// </summary>
    Ped Character { get; }

    /// <summary>
    /// Gets or sets how much money this <see cref="Player"/> has.
    /// </summary>
    int Money { get; set; }

    /// <summary>
    /// Gets or sets the wanted level for this <see cref="Player"/>.
    /// </summary>
    int WantedLevel { get; set; }

    /// <summary>
    /// Sets a value indicating whether cops will be dispatched for this <see cref="Player"/>
    /// </summary>
    bool DispatchsCops { set; }

    /// <summary>
    /// Gets a value indicating whether this <see cref="Player"/> can start a mission.
    /// </summary>
    bool CanStartMission { get; }

    /// <summary>
    /// Gets or sets a value indicating whether this <see cref="Player"/> can control its <see cref="Ped"/>.
    /// </summary>
    bool CanControlCharacter { get; set; }

    /// <summary>
    /// Gets or sets the position of the <see cref="Player"/> current <see cref="Entity"/>.
    /// </summary>
    Vector3 Position { get; set; }

    /// <summary>
    /// Gets a value indicating whether this <see cref="Player"/> is dead.
    /// </summary>
    bool IsDead { get; }

    /// <summary>
    /// Determines whether the <see cref="Player"/> is in range of a specified position.
    /// </summary>
    /// <param name="position">The position to check.</param>
    /// <param name="range">The maximum range.</param>
    /// <returns>
    /// <see langword="true"/> if the <see cref="Player"/> is in range of the <paramref name="position"/>;
    /// otherwise, <see langword="false"/>.
    /// </returns>
    bool IsInRange(Vector3 position, float range);

    /// <summary>
    /// Determines whether the <see cref="Player"/> is near a specified <see cref="Entity"/>.
    /// </summary>
    /// <param name="entity">The <see cref="Entity"/> to check.</param>
    /// <param name="bounds">The max displacement from the entity.</param>
    /// <returns>
    /// <see langword="true"/> if the <see cref="Player"/> is near the specified <paramref name="entity"/>;
    /// otherwise, <see langword="false"/>.
    /// </returns>
    bool IsNearEntity(Entity entity, Vector3 bounds);

    /// <summary>
    /// Determines whether this <see cref="Player"/> is targeting the specified <see cref="Entity"/>.
    /// </summary>
    /// <param name="entity">The <see cref="Entity"/> to check.</param>
    /// <returns>
    /// <see langword="true"/> if the <see cref="Player"/> is targeting the specified <paramref name="entity"/>;
    /// otherwise, <see langword="false"/>.
    /// </returns>
    bool IsTargeting(Entity entity);
}
using GTA;
using GTA.Math;
using LSDW.Abstractions.Domain.Providers;

namespace LSDW.Domain.Providers;

/// <summary>
/// The player provider class.
/// </summary>
/// <remarks>
/// Wrapper for the <see cref="Player"/> methods and properties.
/// </remarks>
internal sealed class PlayerProvider : IPlayerProvider
{
    public Ped Character
        => Game.Player.Character;

    public int Money
    {
        get => Game.Player.Money;
        set => Game.Player.Money = value;
    }

    public int WantedLevel
    {
        get => Game.Player.WantedLevel;
        set => Game.Player.WantedLevel = value;
    }

    public bool CanStartMission
        => Game.Player.CanStartMission;

    public bool CanControlCharacter
    {
        get => Game.Player.CanControlCharacter;
        set => Game.Player.CanControlCharacter = value;
    }
    public bool DispatchsCops
    {
        set => Game.Player.DispatchsCops = value;
    }

    public bool IsDead
        => Game.Player.IsDead;

    public Vector3 Position
    {
        get => Game.Player.Character.Position;
        set => Game.Player.Character.Position = value;
    }

    public bool IsInRange(Vector3 position, float range)
        => Game.Player.Character.IsInRange(position, range);

    public bool IsNearEntity(Entity entity, Vector3 bounds)
        => Game.Player.Character.IsNearEntity(entity, bounds);

    public bool IsTargeting(Entity entity)
        => Game.Player.IsTargeting(entity);
}

But maybe in the end it's just up to the developer, how he develops against or with an API, to get abstractions to be able to test better or not.

kagikn commented 1 year ago

I thought of nuget packages providing interfaces for abstraction for API layers, but I am speculating you'll end up dealing with versioning issues on interface implementation (such as old and new version resolution). However, maybe we shouldn't create many custom interfaces in API itself in the first place, since you would think of mocking typically when you use third party libraries IMO. I haven't used mocks much due to the nature of game modding where you can't exactly predict how functions embedded in the game works without people with good reverse engineering skill, but using your own mocks is a generally better option to me. I remember when I mocked my own sockets where a Web API is used in production in a tiny web app of mine a year ago, but don't remember when I used some mocks provided by libraries themselves (not talking about libraries such as Jest).

As for PlayerProvider you provided, it doesn't seem to me mock does a job for properties too good, since none of them doesn't execute complex logics afaik. Game.Player.Character only fetches local player index (always zero in SP) and gets the player ped handle (will have to register script entity index, but still not too complex). Player.CanControlCharacter basically reads/writes a value from/to a 4-byte value and that's it. Player.IsDead will only check if the player ped is not lower than the fatal injury threshold.

kagikn commented 1 year ago

Enabled GitHub Discussions for more potential engagement in discussions. crosire wouldn't have enabled this, but I'm giving it a try. Maybe we can spot design flaws more easily. Discord server for SHVDN? I'll need more time to set up for that.

RemixPL1994 commented 7 months ago

Enabled GitHub Discussions for more potential engagement in discussions. crosire wouldn't have enabled this, but I'm giving it a try. Maybe we can spot design flaws more easily. Discord server for SHVDN? I'll need more time to set up for that.

Have you been able to move forward on Discord? ;)