| 欢迎, 游客 |
您必须先 注册 才能在我们的网站上发帖.
|
| 论坛统计 |
» 会员总数: 8
» 新进会员: 柚子爱吃包子
» 主题总数: 30
» 帖子总数: 40
完整统计
|
| 在线用户 |
当前共有 4 位在线用户. » 0 会员 | 4 游客
|
| 最新主题 |
[插件] kook-connect
板块: 插件
最后发表: siwode
05-01-2026, 01:07 AM
» 回复: 0
» 浏览: 44
|
openmp/samp联机服务器插件开发 完全指南
板块: 教程
最后发表: 柚子爱吃包子
04-09-2026, 09:14 PM
» 回复: 2
» 浏览: 186
|
圣安地列斯联机二十年:SA:MP 与 open.m...
板块: 综合讨论
最后发表: siwode
03-26-2026, 12:40 PM
» 回复: 3
» 浏览: 216
|
[考古] openmp 常见问题解答 | 2020...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:55 PM
» 回复: 0
» 浏览: 42
|
[考古]MTA 团队与 open.mp 团队早期就...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:53 PM
» 回复: 0
» 浏览: 40
|
服务器开发 精选资源清单
板块: 发布
最后发表: 小鸟unsigned
03-22-2026, 12:24 AM
» 回复: 0
» 浏览: 137
|
[教程] 枚举器 enum 详细讲解 原文作者: ...
板块: 教程
最后发表: 小鸟unsigned
03-22-2026, 12:11 AM
» 回复: 0
» 浏览: 58
|
[教程] Pawn 中的压缩字符串 原文作者: E...
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:52 PM
» 回复: 0
» 浏览: 51
|
[修仙者篇] AMX 汇编文档
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:20 PM
» 回复: 1
» 浏览: 81
|
[wiki系列] openmp/samp 关键字:...
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:06 PM
» 回复: 0
» 浏览: 48
|
|
|
| [插件] kook-connect |
|
发布者: siwode - 05-01-2026, 01:07 AM - 板块: 插件
- 暂无回复
|
 |
samp-kook-connector 插件 Wiki
将 Kook 机器人集成到你的 SA-MP / open.mp 服务器游戏模式中。
目录
- 安装
- 重要说明
- Natives(原生函数)
- Callbacks(回调函数)
1. 安装
1.1 创建 Kook 机器人
- 访问 https://developer.kookapp.cn/
- 创建一个新的应用 / 机器人
- 输入机器人名称并完成创建
- 从机器人设置页面获取你的 机器人令牌(Bot Token)
- 通过开发者门户或邀请链接,将机器人邀请到你的服务器
1.2 配置令牌
SA-MP(server.cfg):
代码: kook_bot_token YOUR_BOT_TOKEN_HERE
open.mp(config.json):
代码: kook.bot_token YOUR_BOT_TOKEN_HERE
也可以使用环境变量代替:
代码: SAMP_KOOK_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
引用:注意事项:- 永远不要将机器人令牌分享给任何人
- 确保服务器时钟定期同步,否则速率限制器将无法正常工作,机器人可能会被封禁
- 此插件专为 open.mp (OMP) 服务器设计
2. 重要说明
引用:关于 Kook ID 与内部 ID:
Kook ID 是一个很大的数字,无法放入 PAWN 的 32 位整数中。因此插件采用两套 ID 体系:- Kook ID:Kook 平台使用的真实 ID,必须以字符串形式传递
- 内部 ID:插件内部使用的整数索引,通过 KCC_Find* / KCC_Get* 系列函数获取
你的脚本只需持有内部 ID(索引),插件会在内部维护与真实 ID 的映射关系。
引用:关于发送私信:
向用户发送私信需要两步:
- 调用 KCC_CreatePrivateChannel(user, "回调函数名") 打开私信频道
- 在回调函数中调用 KCC_GetCreatedPrivateChannel() 获取频道 ID
- 使用该频道 ID 调用 KCC_SendChannelMessage 等频道函数发送消息
3. Natives(原生函数)
3.1 频道
KOOK_FindChannelById
代码: KOOK_FindChannelById(const channel_id[])
通过 Kook ID 查找频道,返回带 KCC_Channel 标签的内部 ID。
KCC_GetChannelId
代码: KCC_GetChannelId(KCC_Channel:channel, dest[DCC_ID_SIZE], max_size = sizeof dest)
获取频道的 Kook ID,以文本形式存入 dest。
KCC_GetChannelType
代码: KCC_GetChannelType(KCC_Channel:channel, &KCC_ChannelType:type)
获取频道类型,存入 type。
KCC_GetChannelGuild
代码: KCC_GetChannelGuild(KCC_Channel:channel, &KCC_Guild:guild)
获取频道所属服务器,存入 guild。
KCC_GetChannelName
代码: KCC_GetChannelName(KCC_Channel:channel, dest[], max_size = sizeof dest)
获取频道名称,存入 dest。
KCC_GetChannelTopic
代码: KCC_GetChannelTopic(KCC_Channel:channel, dest[], max_size = sizeof dest)
获取频道主题,存入 dest。
KCC_GetChannelPosition
代码: KCC_GetChannelPosition(KCC_Channel:channel, &position)
获取频道排列位置,存入 position。
KCC_SendChannelMessage
代码: KCC_SendChannelMessage(KCC_Channel:channel, const message[], const callback[] = "", const format[] = "", {Float, _}:...)
向指定频道发送消息。callback(可选)为消息发送确认后调用的公共函数,format 及后续参数为回调参数。
KCC_SetChannelName
代码: KCC_SetChannelName(KCC_Channel:channel, const name[])
修改频道名称。
KCC_SetChannelTopic
代码: KCC_SetChannelTopic(KCC_Channel:channel, const topic[])
修改频道主题。
KCC_DeleteChannel
代码: KCC_DeleteChannel(KCC_Channel:channel)
删除指定频道。
3.2 消息
KCC_GetMessageId
代码: KCC_GetMessageId(KCC_Message:message, dest[KCC_ID_SIZE], max_size = KCC_ID_SIZE)
获取消息的 Kook ID,存入 dest。
KCC_GetMessageChannel
代码: KCC_GetMessageChannel(KCC_Message:message, &KCC_Channel:channel)
获取消息所在频道,存入 channel。
KCC_GetMessageAuthor
代码: KCC_GetMessageAuthor(KCC_Message:message, &KCC_User:author)
获取消息发送者,存入 author。
KCC_GetMessageContent
代码: KCC_GetMessageContent(KCC_Message:message, dest[], max_size = sizeof dest)
获取消息文本内容,存入 dest。
KCC_IsMessageMentioningEveryone
代码: KCC_IsMessageMentioningEveryone(KCC_Message:message, &bool:mentions_everyone)
检查消息是否 @全体成员。
KCC_GetMessageUserMentionCount
代码: KCC_GetMessageUserMentionCount(KCC_Message:message, &mentioned_user_count)
获取消息中提及的用户数量。
KCC_GetMessageUserMention
代码: KCC_GetMessageUserMention(KCC_Message:message, offset, &KCC_User:mentioned_user)
按索引获取消息中提及的某个用户。
KCC_GetMessageRoleMentionCount
代码: KCC_GetMessageRoleMentionCount(KCC_Message:message, &mentioned_role_count)
获取消息中提及的身份组数量。
KCC_GetMessageRoleMention
代码: KCC_GetMessageRoleMention(KCC_Message:message, offset, &KCC_Role:mentioned_role)
按索引获取消息中提及的某个身份组。
KCC_DeleteMessage
代码: KCC_DeleteMessage(KCC_Message:message)
在 Kook 上删除该消息。
KCC_GetCreatedMessage
代码: KCC_GetCreatedMessage()
在 KCC_SendChannelMessage 的回调中使用,返回刚发送的消息的内部 ID(带 KCC_Message 标签)。
KCC_DeleteInternalMessage
代码: KCC_DeleteInternalMessage(KCC_Message:message)
从插件内部存储中移除该消息(不会在 Kook 上删除消息),用于持久化消息的清理。
KCC_EditMessage
代码: KCC_EditMessage(KCC_Message:message, const content[], KCC_Embed:embed = KCC_Embed:0)
编辑消息内容,可选附带嵌入消息。
KCC_SetMessagePersistent
代码: KCC_SetMessagePersistent(KCC_Message:message, bool:persistent)
设置消息是否持久保存在插件内部存储中。
KCC_CacheChannelMessage
代码: KCC_CacheChannelMessage(const channel_id[DCC_ID_SIZE], const message_id[DCC_ID_SIZE], const callback[] = "", const format[] = "", {Float, _}:...)
通过 Kook ID 将指定消息缓存到插件中,缓存完成后调用 callback。
3.3 用户
引用:用户的 discriminator 是附加在 Kook 用户名后的 4 位数字标识。
KCC_FindUserByName
代码: KCC_FindUserByName(const user_name[], const user_discriminator[])
通过用户名 + discriminator 查找用户,返回带 KCC_User 标签的内部 ID。
KCC_FindUserById
代码: KCC_FindUserById(const user_id[])
通过 Kook ID 查找用户,返回带 KCC_User 标签的内部 ID。
KCC_GetUserId
代码: KCC_GetUserId(KCC_User:user, dest[DCC_ID_SIZE], max_size = DCC_ID_SIZE)
获取用户的 Kook ID,存入 dest。
KCC_GetUserName
代码: KCC_GetUserName(KCC_User:user, dest[DCC_USERNAME_SIZE], max_size = sizeof dest)
获取用户名,存入 dest。
KCC_GetUserDiscriminator
代码: KCC_GetUserDiscriminator(KCC_User:user, dest[], max_size = DCC_ID_SIZE)
获取用户的 discriminator,存入 dest。
KCC_IsUserBot
代码: KCC_IsUserBot(KCC_User:user, &bool:is_bot)
检查用户是否为机器人。
KCC_IsUserVerified
代码: KCC_IsUserVerified(KCC_User:user, &bool:is_verified)
检查用户是否已完成验证。
3.4 身份组
引用:dest 字符串大小必须为 DCC_ID_SIZE。
KCC_FindRoleByName
代码: KCC_FindRoleByName(KCC_Guild:guild, const role_name[])
在指定服务器中按名称查找身份组,返回带 KCC_Role 标签的内部 ID。
KCC_FindRoleById
代码: KCC_FindRoleById(const role_id[])
通过 Kook ID 查找身份组,返回带 KCC_Role 标签的内部 ID。
KCC_GetRoleId
代码: KCC_GetRoleId(KCC_Role:role, dest[DCC_ID_SIZE], max_size = sizeof dest)
获取身份组的 Kook ID,存入 dest。
KCC_GetRoleName
代码: KCC_GetRoleName(KCC_Role:role, dest[], max_size = sizeof dest)
获取身份组名称,存入 dest。
KCC_GetRoleColor
代码: KCC_GetRoleColor(KCC_Role:role, &color)
获取身份组颜色值,存入 color。
KCC_GetRolePermissions
代码: KCC_GetRolePermissions(KCC_Role:role, &perm_high, &perm_low)
获取身份组权限(64 位整数分为高低两个 32 位部分)。
KCC_IsRoleHoist
代码: KCC_IsRoleHoist(KCC_Role:role, &bool:is_hoist)
检查身份组是否在成员列表中单独显示(提升显示)。
KCC_GetRolePosition
代码: KCC_GetRolePosition(KCC_Role:role, &position)
获取身份组的排列位置,存入 position。
KCC_IsRoleMentionable
代码: KCC_IsRoleMentionable(KCC_Role:role, &bool:is_mentionable)
检查身份组是否可被 @ 提及。
3.5 服务器
引用:dest 字符串大小必须为 DCC_ID_SIZE。
— 查询 —
KCC_FindGuildByName
代码: KCC_FindGuildByName(const guild_name[])
按名称查找服务器,返回带 KCC_Guild 标签的内部 ID。
KCC_FindGuildById
代码: KCC_FindGuildById(const guild_id[])
通过 Kook ID 查找服务器,返回带 KCC_Guild 标签的内部 ID。
KCC_GetGuildId
代码: KCC_GetGuildId(KCC_Guild:guild, dest[DCC_ID_SIZE], max_size = sizeof dest)
获取服务器的 Kook ID,存入 dest。
KCC_GetGuildName
代码: KCC_GetGuildName(KCC_Guild:guild, dest[], max_size = sizeof dest)
获取服务器名称,存入 dest。
KCC_GetGuildOwnerId
代码: KCC_GetGuildOwnerId(KCC_Guild:guild, dest[DCC_ID_SIZE], max_size = sizeof dest)
获取服务器所有者的 Kook 用户 ID,存入 dest。
KCC_GetAllGuilds
代码: KCC_GetAllGuilds(KCC_Guild:dest[], max_size = sizeof dest)
获取所有服务器的内部 ID,存入 dest 数组。
— 频道 —
KCC_GetGuildChannel
代码: KCC_GetGuildChannel(KCC_Guild:guild, offset, &KCC_Channel:channel)
按索引获取服务器中的某个频道。
KCC_GetGuildChannelCount
代码: KCC_GetGuildChannelCount(KCC_Guild:guild, &count)
获取服务器的频道总数。
KCC_CreateGuildChannel
代码: KCC_CreateGuildChannel(KCC_Guild:guild, const name[], DCC_ChannelType:type, const callback[] = "", const format[] = "", {Float, _}:...)
在服务器中创建新频道,完成后调用 callback。
KCC_GetCreatedGuildChannel
代码: KCC_GetCreatedGuildChannel()
在 KCC_CreateGuildChannel 的回调中使用,返回新创建频道的内部 ID。
— 成员 —
KCC_GetGuildMember
代码: KCC_GetGuildMember(KCC_Guild:guild, offset, &KCC_User:user)
按索引获取服务器中的某个成员。
KCC_GetGuildMemberCount
代码: KCC_GetGuildMemberCount(KCC_Guild:guild, &count)
获取服务器成员总数。
KCC_GetGuildMemberVoiceChannel
代码: KCC_GetGuildMemberVoiceChannel(KCC_Guild:guild, KCC_User:user, &KCC_Channel:channel)
获取成员当前所在的语音频道。
KCC_GetGuildMemberNickname
代码: KCC_GetGuildMemberNickname(KCC_Guild:guild, KCC_User:user, dest[DCC_NICKNAME_SIZE], max_size = sizeof dest)
获取成员在服务器中的昵称。
KCC_GetGuildMemberRole
代码: KCC_GetGuildMemberRole(KCC_Guild:guild, KCC_User:user, offset, &KCC_Role:role)
按索引获取成员拥有的某个身份组。
KCC_GetGuildMemberRoleCount
代码: KCC_GetGuildMemberRoleCount(KCC_Guild:guild, KCC_User:user, &count)
获取成员拥有的身份组数量。
KCC_HasGuildMemberRole
代码: KCC_HasGuildMemberRole(KCC_Guild:guild, KCC_User:user, KCC_Role:role, &bool:has_role)
检查成员是否拥有指定身份组。
KCC_SetGuildMemberNickname
代码: KCC_SetGuildMemberNickname(KCC_Guild:guild, KCC_User:user, const nickname[])
修改成员昵称。
KCC_AddGuildMemberRole
代码: KCC_AddGuildMemberRole(KCC_Guild:guild, KCC_User:user, KCC_Role:role)
为成员添加身份组。
KCC_RemoveGuildMemberRole
代码: KCC_RemoveGuildMemberRole(KCC_Guild:guild, KCC_User:user, KCC_Role:role)
移除成员的某个身份组。
KCC_RemoveGuildMember
代码: KCC_RemoveGuildMember(KCC_Guild:guild, KCC_User:user)
踢出服务器成员。
KCC_CreateGuildMemberBan
代码: KCC_CreateGuildMemberBan(KCC_Guild:guild, KCC_User:user, const reason[] = "")
封禁成员,可附带原因。
KCC_RemoveGuildMemberBan
代码: KCC_RemoveGuildMemberBan(KCC_Guild:guild, KCC_User:user)
解封成员。
— 身份组管理 —
KCC_GetGuildRole
代码: KCC_GetGuildRole(KCC_Guild:guild, offset, &KCC_Role:role)
按索引获取服务器中的某个身份组。
KCC_GetGuildRoleCount
代码: KCC_GetGuildRoleCount(KCC_Guild:guild, &count)
获取服务器的身份组总数。
KCC_CreateGuildRole
代码: KCC_CreateGuildRole(KCC_Guild:guild, const name[], const callback[] = "", const format[] = "", {Float, _}:...)
在服务器中创建新身份组,完成后调用 callback。
KCC_GetCreatedGuildRole
代码: KCC_GetCreatedGuildRole()
在 KCC_CreateGuildRole 的回调中使用,返回新创建身份组的内部 ID。
KCC_DeleteGuildRole
代码: KCC_DeleteGuildRole(KCC_Guild:guild, KCC_Role:role)
删除服务器中的身份组。
KCC_SetGuildRoleName
代码: KCC_SetGuildRoleName(KCC_Guild:guild, KCC_Role:role, const name[])
修改身份组名称。
KCC_SetGuildRolePermissions
代码: KCC_SetGuildRolePermissions(KCC_Guild:guild, KCC_Role:role, perm_high, perm_low)
修改身份组权限(64 位整数分为高低两个 32 位部分)。
KCC_SetGuildRoleColor
代码: KCC_SetGuildRoleColor(KCC_Guild:guild, KCC_Role:role, color)
修改身份组颜色。
KCC_SetGuildRoleHoist
代码: KCC_SetGuildRoleHoist(KCC_Guild:guild, KCC_Role:role, bool:hoist)
设置身份组是否在成员列表中单独显示。
KCC_SetGuildRoleMentionable
代码: KCC_SetGuildRoleMentionable(KCC_Guild:guild, KCC_Role:role, bool:mentionable)
设置身份组是否可被 @ 提及。
3.6 机器人
KCC_CreatePrivateChannel
代码: KCC_CreatePrivateChannel(KCC_User:user, const callback[], const format[] = "", {Float, _}:...)
向指定用户打开私信频道,完成后调用 callback。
KCC_GetCreatedPrivateChannel
代码: KCC_GetCreatedPrivateChannel()
在 KCC_CreatePrivateChannel 的回调中使用,返回私信频道的内部 ID(可直接用于 KCC_SendChannelMessage 等函数)。
3.7 嵌入消息
嵌入消息(Embed)允许向 Kook 频道发送富文本内容消息。
KCC_CreateEmbed
代码: KCC_CreateEmbed(const title[] = "", const description[] = "", const url[] = "", const timestamp[] = "", color = 0, const footer_text[] = "", const footer_icon_url[] = "", const thumbnail_url[] = "", const image_url[] = "")
创建一个嵌入消息,所有参数均为可选,返回带 KCC_Embed 标签的内部 ID。
KCC_DeleteEmbed
代码: KCC_DeleteEmbed(KCC_Embed:embed)
删除嵌入消息。
KCC_SendChannelEmbedMessage
代码: KCC_SendChannelEmbedMessage(KCC_Channel:channel, KCC_Embed:embed, const message[] = "", const callback[] = "", const format[] = "", {Float, _}:...)
向频道发送带嵌入消息的内容,可附带普通文字。
KCC_AddEmbedField
代码: KCC_AddEmbedField(KCC_Embed:embed, const name[], const value[], bool:inline = false)
为嵌入消息添加字段,inline 为 true 时字段并排显示。
KCC_SetEmbedTitle
代码: KCC_SetEmbedTitle(KCC_Embed:embed, const title[])
设置嵌入消息标题。
KCC_SetEmbedDescription
代码: KCC_SetEmbedDescription(KCC_Embed:embed, const description[])
设置嵌入消息描述。
KCC_SetEmbedUrl
代码: KCC_SetEmbedUrl(KCC_Embed:embed, const url[])
设置嵌入消息链接。
KCC_SetEmbedTimestamp
代码: KCC_SetEmbedTimestamp(KCC_Embed:embed, const timestamp[])
设置嵌入消息时间戳。
KCC_SetEmbedColor
代码: KCC_SetEmbedColor(KCC_Embed:embed, color)
设置嵌入消息左侧色条颜色。
KCC_SetEmbedFooter
代码: KCC_SetEmbedFooter(KCC_Embed:embed, const footer_text[], const footer_icon_url[] = "")
设置嵌入消息页脚文本,可附带图标 URL。
KCC_SetEmbedThumbnail
代码: KCC_SetEmbedThumbnail(KCC_Embed:embed, const thumbnail_url[])
设置嵌入消息缩略图。
KCC_SetEmbedImage
代码: KCC_SetEmbedImage(KCC_Embed:embed, const image_url[])
设置嵌入消息主图片。
3.8 表情
引用:dest 字符串大小必须为 DCC_EMOJI_NAME_SIZE。
KCC_CreateEmoji
代码: KCC_CreateEmoji(const name[DCC_EMOJI_NAME_SIZE], const snowflake[DCC_ID_SIZE] = "")
创建一个表情对象,snowflake 为可选的 Kook 雪花 ID,返回带 KCC_Emoji 标签的内部 ID。
KCC_DeleteEmoji
代码: KCC_DeleteEmoji(KCC_Emoji:emoji)
删除表情对象。
KCC_GetEmojiName
代码: KCC_GetEmojiName(KCC_Emoji:emoji, dest[DCC_EMOJI_NAME_SIZE], maxlen = DCC_EMOJI_NAME_SIZE)
获取表情名称,存入 dest。
3.9 反应
KCC_CreateReaction
代码: KCC_CreateReaction(KCC_Message:message, KCC_Emoji:reaction_emoji)
对指定消息添加表情反应。
KCC_DeleteMessageReaction
代码: KCC_DeleteMessageReaction(KCC_Message:message, KCC_Emoji:reaction_emoji = KCC_Emoji:0)
移除消息上的表情反应。reaction_emoji 为 0(默认)时删除该消息上的所有反应。
3.10 其他
KCC_EscapeMarkdown
代码: KCC_EscapeMarkdown(const src[], dest[], max_size = sizeof dest)
对字符串中的 Markdown 特殊字符进行转义,使文本在 Kook 消息中原样显示,不被格式化。
4. Callbacks(回调函数)
4.1 频道回调
KCC_OnChannelCreate
代码: public KCC_OnChannelCreate(KCC_Channel:channel)
触发时机:服务器中有新频道创建时。
KCC_OnChannelUpdate
代码: public KCC_OnChannelUpdate(KCC_Channel:channel)
触发时机:频道信息更新时(名称、权限等)。
KCC_OnChannelDelete
代码: public KCC_OnChannelDelete(KCC_Channel:channel)
触发时机:频道被删除时。
4.2 消息回调
KCC_OnMessageCreate
代码: public KCC_OnMessageCreate(KCC_Message:message)
触发时机:频道中有新消息发送时。
KCC_OnMessageDelete
代码: public KCC_OnMessageDelete(KCC_Message:message)
触发时机:消息被删除时。
KCC_OnMessageReaction
代码: public KCC_OnMessageReaction(KCC_Message:message, KCC_User:reaction_user, KCC_Emoji:emoji, DCC_MessageReactionType:reaction_type)
触发时机:消息添加或移除表情反应时。- message:消息内部 ID
- reaction_user:操作反应的用户内部 ID
- emoji:使用的表情内部 ID
- reaction_type:反应事件类型(添加 / 移除等)
4.3 用户回调
KCC_OnUserUpdate
代码: public KCC_OnUserUpdate(KCC_User:user)
触发时机:用户资料更新时(用户名、头像等)。
4.4 服务器回调
KCC_OnGuildCreate
代码: public KCC_OnGuildCreate(KCC_Guild:guild)
触发时机:服务器创建,或机器人加入新服务器时。
KCC_OnGuildUpdate
代码: public KCC_OnGuildUpdate(KCC_Guild:guild)
触发时机:服务器信息更新时(名称、图标等)。
KCC_OnGuildDelete
代码: public KCC_OnGuildDelete(KCC_Guild:guild)
触发时机:服务器删除,或机器人离开服务器时。
KCC_OnGuildMemberAdd
代码: public KCC_OnGuildMemberAdd(KCC_Guild:guild, KCC_User:user)
触发时机:用户加入服务器时。
KCC_OnGuildMemberUpdate
代码: public KCC_OnGuildMemberUpdate(KCC_Guild:guild, KCC_User:user)
触发时机:成员信息更新时(昵称、身份组等)。
KCC_OnGuildMemberVoiceUpdate
代码: public KCC_OnGuildMemberVoiceUpdate(KCC_Guild:guild, KCC_User:user, KCC_Channel:channel)
触发时机:成员语音状态变化时(加入 / 离开语音频道)。- channel:语音频道内部 ID,用户离开语音时该值可能无效
KCC_OnGuildMemberRemove
代码: public KCC_OnGuildMemberRemove(KCC_Guild:guild, KCC_User:user)
触发时机:用户离开或被踢出服务器时。
KCC_OnGuildRoleCreate
代码: public KCC_OnGuildRoleCreate(KCC_Guild:guild, KCC_Role:role)
触发时机:服务器中创建新身份组时。
KCC_OnGuildRoleUpdate
代码: public KCC_OnGuildRoleUpdate(KCC_Guild:guild, KCC_Role:role)
触发时机:服务器身份组更新时(名称、颜色、权限等)。
KCC_OnGuildRoleDelete
代码: public KCC_OnGuildRoleDelete(KCC_Guild:guild, KCC_Role:role)
触发时机:服务器身份组被删除时。
|
|
|
|
| 圣安地列斯联机二十年:SA:MP 与 open.mp 历史 |
|
发布者: 小鸟unsigned - 03-24-2026, 08:45 PM - 板块: 综合讨论
- 回复 (3)
|
 |
圣安地列斯联机二十年:SA:MP 与 open.mp 历史
2026 年。GTA6 即将在今年 11 月发售,新的时代正在敲门。
也是在这一年,《侠盗猎车手:圣安地列斯》PC 版上市整整二十年,SA:MP 的第一个可玩版本也已走过二十个年头。
一座从未被设计为联机游戏的城市,有人在这里认识了一辈子的朋友。有人在这里第一次学会了写代码。有人在角色扮演服务器里扮演一个JC或一个黑帮头目。有人在一个无聊的下午打开了游戏,然后就再也没有那个下午了。
SA:MP 从来不是一个精致的产品。它是一群人强行塞进一款单人游戏里的多人世界。它有数不清的 bug,有争议不断的管理者,有过漫长的停滞与分裂——它也有 open.mp,有一批不肯让它消失的开发者,有如今可以在手机上随时上线的客户端,不管你还记不记得服务器的 IP,不管你当年是服主、脚本开发者,还是只是一个普通的玩家——你在这里的时间是真实的。
GTA6 会带来它的世界。但圣安地列斯的街道,还在。服务器还在跑,玩家还在上线,有人还在第一次输入 /register。
前言
引用:本文以客观事实为准绳。所有关键事件、人物声明、原始帖文,均完整收录于正文之中,不以外部链接替代实质内容。链接仅作考证索引,供读者溯源查验,不构成叙述本身的一部分。
凡引用原文处,均以引用块标注,并注明来源与时间。凡转述处,均忠实于原意,不加渲染,不作价值评判。
这是一部关于热爱、控制、背叛与重建的故事,也是一个游戏社区在二十年间走过的记录。
卷首语:一个不该有多人模式的游戏
2004 年 10 月 26 日,《侠盗猎车手:圣安地列斯》(Grand Theft Auto: San Andreas)在 PlayStation 2 平台正式发售。2005 年 6 月 10 日,PC 版本上市。
这款游戏从未被设计为多人联机。Rockstar Games 为它设计了一个宏大的单人故事——主角 CJ 在帮派、JC与腐败之间挣扎求生,横跨三座城市、一片乡野。游戏没有联机大厅,没有服务器浏览器,没有任何供多人游玩的接口。
然而在 GTA 模组社区里,有一批人从不接受"这不可能"这个答案。
他们是一群来自世界各地的年轻人,大多数有着普通的日间生活——学生、程序员、上班族——他们一点一点地撬开这款游戏的外壳,试图将它改造成一个可以容纳数百人同时在线的多人世界。
这便是 SA:MP 的起点。
第一卷:诞生(2004–2006)
第一章 从 Vice City 到 San Andreas
在《圣安地列斯》PC 版尚未发售之前,GTA 模组社区已经有人在为《GTA 罪恶都市》(Vice City)制作多人联机改造,这个项目被命名为 VC:MP(Vice City Multiplayer)。
领导这支早期团队的,是一个网名叫 kyeman 的开发者,真名 Kye Bitossi。他在 GTA 模组社区中颇有声望,是公认的技术核心人物。
2005 年 4 月初,kyeman 在全球最大的 GTA 社区论坛 GTAForums 上发布了一篇帖子,正式宣布 SA:MP 项目的存在。帖子标题是:
引用:"[WIP] SA-MP: Multiplayer for San Andreas PC"
——kyeman,GTAForums,2005年4月3日
消息一出,社区立刻沸腾。预告视频和截图开始在各大 GTA 论坛流传,项目的小型社区论坛在尚未开放任何测试的情况下,已吸引了约 120 名注册用户。
公开 beta 测试期间,服务器承载能力远不及涌入的测试者数量,进入测试服务器需要排队等候,等待时间往往很长。这种盛况,是对整个项目最好的背书。
进入开发团队的成员,来自 GTA 模组社区的各个方向,过去的SA-MP 0.2开发者和测试者包括:kyeman、spookie、Y_Less、mike、jax、Mike、Cam、adamcs、bakasan、Born Acorn、Dalpura、Damian、Delfi、dexx、DrAke$、Drift、ECLiPSE、f3llah1n、him selfe、illspirit、littlewhitey、MrJax、njr1489、Posty、PsYcHoGoD、Shizz、Simon、sockx、squiddy、Static、steve-m、The Azer、Trix、Wacko、XcR、[ULK]Crack,其中许多人既是开发者也是测试员。
他们大多是业余爱好者,利用业余时间共同推进这个无人出资的项目。
第二章 合作模式的争议与团队第一次动荡
然而,SA:MP 从一开始便并非一片和谐。
开发团队内部,关于游戏核心玩法的方向,爆发了一场至今仍被一些老玩家提起的根本性争论:
一部分人主张将 SA:MP 建设为一款合作模式(Co-op)游戏,设想最多 6 名玩家共同游玩,保留完整的 NPC 行为、警察追捕系统与任务流程,让多人体验尽可能接近单人游戏的质感;
另一方,以 kyeman 为首,坚持将其建设为一个能够容纳大量玩家同时在线的大规模死亡竞技(Deathmatch)式多人服务器。
这场分歧并未在内部平息,而是溢出到了 GTANet 社区论坛,引发了玩家的大量公开讨论与批评,负面声音之多,令整个项目团队承受了巨大压力。
2005 年 9 月 18 日,kyeman 在持续的内外压力下,宣布因个人事务无暇继续领导项目,将主导权移交给团队二号核心开发者 spooky。
spooky 接手后,将项目更名为 GTA:Multiplayer(GTA:M),以示风格与管理方式的转变。
然而,约一个月后,kyeman 重新归队。合作模式方案在此期间被彻底搁置,项目名称最终改回 San Andreas Multiplayer(SA:MP),方向确定为大规模多人在线游戏。
关于"Grand Theft Auto"字样是否应出现在项目名称中,团队在命名时也有过考量——使用 Rockstar 的商标可能引发法律风险,最终以缩写"SA:MP"作为正式名称,在一定程度上规避了这一问题。
第三章 2006 年:第一个可玩版本
在经历了那段动荡之后,SA:MP 于 2006 年 发布了第一个向公众开放的可玩版本。
从技术层面看,这是一项相当可观的成就:开发者们在完全没有官方支持的前提下,用逆向工程的方式强行将联机功能"嵌入"进一款从未被设计为多人联机的作品,使其能够支持最多 1000 名玩家同时在线,在同一片虚拟的圣安地列斯地图中驾车、射击、竞速。
服务器使用 Pawn 作为脚本语言,服务器主可以自行编写游戏模式(GameMode)与过滤脚本(FilterScript)。这一设计赋予了整个生态极高的开放性——任何人只要学会 Pawn,就可以在圣安地列斯的地图上建造出属于自己的世界。
0.2.2 版本是这一阶段的代表版本,也是现存有据可查的最早可玩 SA:MP 版本记录之一。
第二卷:黄金时代与暗流(2006–2009)
第四章 版本迭代与社区的兴盛
2006 年至 2014 年,是 SA:MP 最活跃的开发阶段。Kalcor(此时 kyeman 已开始使用这个网名)陆续发布了一系列重要版本:
0.2 系列(2006–2008)期间,SA:MP 奠定了基本架构,确立了 Pawn 脚本语言作为游戏模式开发的标准。这段时间并非全然顺利——一次大规模安全漏洞爆发,险些让整个团队就此放弃。而最终让他们坚持下来的,是一份在线请愿:短短时间内,数以千计的玩家签名支持 SA:MP 继续存在。这份来自社区的温度,让开发者重新燃起了动力。
0.3a(约 2010 年)是 SA:MP 历史上最具里程碑意义的版本之一。单服务器玩家上限从原来的数百人扩展至 500 人(后续版本更高),引入了鼠标驱动的记分板与聊天界面,增加了可编程的 NPC 机器人系统(允许服务器创建可驾驶火车、飞机的脚本机器人),网络同步质量大幅改善。
此后数年,0.3c、0.3d、0.3e、0.3x、0.3z 相继发布,带来了音频流媒体支持、延迟补偿模式、自定义对象渲染距离、新的安全更新等功能。每一个版本的发布,都在 SA:MP 官方论坛的"新闻与更新"板块留下了一条由 Kalcor 亲自发布的公告。
2015 年 5 月 1 日,Kalcor 发布了 SA:MP 0.3.7,这是 SA:MP 有史以来最后一个带有实质性新功能的正式版本。0.3.7 新增了超过 500 个物体 ID(含特技物件与地形物件)、界面字体大小调整选项、若干新版警察皮肤、服务端对车门与车窗的控制权、为未标记警车添加警报灯的能力,以及一个简单的静态 NPC actor 系统。
这个版本在此后数年间成为整个 SA:MP 生态的事实标准,直至今日仍是服务器主最常使用的版本。
与此同时,这个社区在技术之外也在野蛮生长。角色扮演(RP)服务器在这段时期走向成熟,玩家们在 SA:MP 的世界中建构起了拥有经济系统、帮派政治、法律体系的虚拟社会。来自世界各地大型服务器先后崛起。SA:MP 不再只是一个小众模组,它已经成为一个真实的、活跃的、跨越语言与国境的国际社区。
第五章 Y_Less 的双重身份与 YSI 的诞生
在 SA:MP 的所有社区开发者中,有一个人的名字几乎是无可回避的:Y_Less,真名 Alex Cole。
Y_Less 最初以 beta 测试员的身份加入 SA:MP 团队,随后正式成为开发团队成员,主要负责 Pawn 脚本语言相关的功能开发。然而,他与 Kalcor 之间的分歧,从他正式入队的第一天起便已埋下。
用 Y_Less 自己后来的话说:
引用:"加入开发团队之后,我开始开发 Pawn,但我发现自己能做的事被 Kye 严重限制。我们之间存在重大的意见分歧:他认为 90% 的开发精力应该投入同步(sync)工作,Pawn 几乎只是顺手提供一点额外功能的附属品。而我认为,虽然同步很重要,但他已经把同步覆盖得很好了,我更想专注于 Pawn 的功能与函数。然而他是老板,说了算,严重限制了我被允许做的事。此外,他有个坏习惯——每次版本迭代之后,只要在某个细枝末节的测试中有任何不稳定,就要来指责我,尽管测试本来就是我们有 beta 测试团队的原因。不用说,这不是一个好的工作环境,但我仍然喜欢 SA:MP 这个产品本身。"
在这种压抑的工作环境中,YLess 于 2007 年 开始独立开发 YSI(YLess' Server/Script Includes)。
YSI 最初是他此前若干工具的整合与升级,同时也是他在 Kalcor 的管控边界之外,以纯 Pawn 脚本的方式,实现他认为 SA:MP 脚本系统应当具备的扩展能力。他在这个过程中也加入了一些辅助性的函数,例如 player objects 功能,最初是专门为了支撑他的对象流式传输器 e_objects 而写的。
用 Y_Less 自己的话说:
引用:"2007 年,我开始开发 YSI。这部分是对若干早期工具的更新与整合,部分也是在 Kye 的控制之外,以 Pawn 能力范围内的方式带来我认为 Pawn 所需的扩展功能——尽管以纯 Pawn 来做,能实现的事情自然有其上限。YSI 成为我的主要项目这一事实惹恼了 Kye,因为他认为这说明我没有把时间花在 SA:MP 上,尽管我实际上一直都在。"
YSI 涵盖了命令处理(ycommands)、玩家迭代器(yiterate)、钩子机制(yhooks)、INI 文件读写(yini)、多语言支持(y_languages)等数十个子模块,是当时 SA:MP 社区功能最全面、使用最广泛的第三方脚本库。
至于"YSI"到底代表什么,连 Y_Less 本人也说不清:
引用:"最初的想法是'Y[i]Less' Server Includes',但 Server 和 Script 经常被混用,而且现在参与的开发者不止 YLess 一人,所以'Y'就变成了一个递归缩写,代表'YSI'本身。"[/i]
第六章 2008 年底:GTANet 风波与团队大出走
2008 年底,SA:MP 开发团队内部爆发了一场与 GTANet 社区相关的严重矛盾。关于这场风波的具体细节,现有的公开资料已难以完整还原,但其结果是清晰且影响深远的:Mike、aru 和 Peter 等多名核心开发者相继以此为由宣布离队。
Y_Less 在日后的声明中这样描述这段经历:
引用:"2008 年底,GTANet 风波爆发,Mike、aru 和 Peter 因此离开,我也因为团队氛围实在太差,加之大家都在走,便也一同离开了。"
这一波出走之后,SA:MP 的开发工作几乎完全落回 Kalcor 一人肩上,这进一步强化了整个项目对他个人的高度依赖,也使得社区的任何集体意见都更加难以产生实质影响。
然而事情并未就此平息。Y_Less 出走后,出于他自己也说不清的原因,重新回归了团队。他在后来的声明中写道:
引用:"不知为何,真的记不清了,我之后又重新加入了团队。那时候 Kye 想要关闭 SA:MP,我提出愿意接手,他拒绝了。然后我再次退出——不是因为我没接手到,这一点需要说明——于是 Kye 决定封禁我的论坛账号,而且不只是封号,而是彻底抹除——删掉了我所有的帖子。这意味着原版《实用函数》帖没了,旧版 FAQ 帖没了,大量迷你教程没了,《Pawn 关键词》帖也没了——我只有在非常必要的时候才发帖,那些帖子都有其存在的价值,它们的消失被大量用户注意到并提出了强烈抗议。"
这便是 2008 年底那次"第一次封号"事件的始末——相对低调,但已经是一个信号。真正震动整个社区的风波,还在后面。
第七章 2009 年 1 月:YSF 封号事件
从团队再度离开之后,YLess 没有停止为社区创作工具。他开发了 YSF(YLess' Server Fixes)——一个以服务端插件形式存在的 bug 修复集合。
YSF 的设计理念很简单:SA:MP 有很多 Kalcor 长期忽略或拒绝修复的 bug,既然拿不到源代码,那就通过内存操作(memory patching)的方式,在运行时强行修正这些行为。这个方法技术上相当复杂,但效果立竿见影。
Y_Less 事后这样形容 YSF:
引用:"YSF 是我最成功的项目,因为它做到了所有人都想要的事。它本质上是一个更新版的服务端——人们总是渴望新版本带来修复和新功能,功能方面已经有其他插件(包括 YSI 插件)覆盖了,所以我选择了修复这条路(以及一些小功能——显然正是这些小功能引发了麻烦)。"
YSF 在社区中广泛流传,大量服务器安装并使用它。
2009 年 1 月 19 日,事件的直接导火索被点燃。
当天,Kalcor 私信给 Y_Less,表达了对 YSF 的不满。他的顾虑有两项:
第一,他认为 YSF 可能与 SA:MP 未来版本产生脚本兼容性问题;第二,他认为 YSF 代码中存在与 SA:MP 服务端源代码相似的内容,以 functions.cpp 中的 IsNickInvalid 函数为例。
Y_Less 对此逐一作出回应。关于兼容性问题,他指出 YSF 恰恰是在让脚本对未来版本更加兼容,而非相反;关于源代码问题,他坦言:
引用:"这实际上并非盗用,但我承认看起来非常相似,因为它做的事情完全一样,而且也不是什么复杂的函数,我懒得争,就直接把它彻底重写了。"
他还顺带提到,Kalcor 自己曾主导过一些令他反对的修改决策——比如改变服务端的默认运行风格,以及在 0.3 版本中移除对象(最后改为以 IPL 文件形式替代,而非完全移除)——当初他的反对意见被 Kalcor 直接否决。
双方讨论的最终结果,表面上是达成了共识:Kalcor 表示接受了 Y_Less 的解释,YSF 可以继续存在。
然而,Y_Less 第二天登录论坛,发现自己已经被封号。
他在后来的声明中还原了这段过程:
引用:"就在我们讨论同意 YSF 可以继续存在,之后,他发现了 SetServerRule,并意识到它可以用来修改只读控制台变量——比如服务器版本号——从而让人们能够违反 SA:MP 服务条款第(g)条:'你不得人为地增加玩家数量,或向 SA:MP 查询机制提供虚假信息。'"
Kalcor 认为,Y_Less 在 YSF 中提供了这样一个功能,就等同于为违规行为提供了手段,因此必须承担责任。
封号通知的原文如下(Y_Less 在声明中直接引用):
引用:*"抱歉 ssǝן‾ʎ,你已被禁止使用本论坛!
滥用前开发者身份。入侵服务器,并为他人违反服务条款提供手段。"*
封号的同时,Kalcor 再次删除了 Y_Less 在 SA:MP 官方论坛上的全部历史帖子。大量教程、工具发布帖、FAQ 就此永久消失,众多用户注意到这些帖子的蒸发,并提出了强烈抗议。
Y_Less 随即在社区发布了一篇完整声明,以下是他原文的核心段落:
引用:"基本上,我因为 YSF 被 SA:MP 官方论坛封号了!
历史:
200x 年(实在记不清了),在与 Spookie 讨论之后,我以 beta 测试员的身份加入了 SA:MP 团队——那还是第一次公开 beta 之前的事——并开始编写 Pawn 相关的代码。
没过多久,我正式加入了开发团队,专注于 Pawn 的开发,但我很快发现自己受到 Kye 的诸多限制,做不了想做的事。我们之间存在重大分歧:他认为 90% 的开发精力应该用于同步(sync),Pawn 只是顺手提供一点额外功能的附属品;而我认为,虽然同步很重要,但他已经把同步做得很好了,我更想把时间花在 Pawn 的功能扩展上。然而他是老板,说了算,严重限制了我能做的事。更让人受不了的是,他有个习惯——每次版本迭代后,只要在某个犄角旮旯的测试里出了任何小问题,就要来责怪我,哪怕我们有 beta 测试团队本来就是为了发现这些问题的。说实话那不是一个好的工作环境,但我还是喜欢 SA:MP 这个产品本身。
2007 年,我开始开发 YSI。这一方面是对此前一批旧项目的整合升级,另一方面也是想在 Kye 的管控之外,以纯 Pawn 脚本的方式实现我认为 Pawn 所需要的扩展功能——尽管能做到的事情有限,毕竟纯 Pawn 本身有其局限(我确实额外添加了一些辅助函数,比如 player objects 功能完全是为了写我最初的对象流式传输器 e\_objects 而专门实现的)。YSI 成为我的主要项目这件事惹恼了 Kye,因为他觉得我没有把时间花在 SA:MP 上——尽管我实际上一直都在。
2008 年底,GTANet 社区风波爆发,Mike、aru 和 Peter 因此离队。我也因为团队氛围实在太差,加上大家都在走,便也跟着离开了。
不知为何(真的记不清了),我之后又重新回归了团队。那时候 Kye 想要关闭 SA:MP,我主动提出愿意接手,他拒绝了。然后我再次退出——不是因为没接手到,这一点需要说清楚——于是 Kye 决定封禁我的论坛账号,而且不只是封号,而是彻底抹除,删掉了我所有的帖子。这意味着原版《实用函数》帖没了,旧版 FAQ 帖没了,大量迷你教程没了,《Pawn 关键词》帖也没了——我只有在非常必要的时候才会发帖,那些帖子都有其价值,它们的消失被大量用户注意到并提出了抗议。
不管怎样,我还是喜欢 SA:MP 和这个社区本身(至少喜欢其中的一部分),所以我还是留了下来(现在回想起来真不知道当时为什么,深感后悔),并继续努力让 SA:MP 变得更好——尽管 Kye 的存在让这件事很难。我继续开发 YSI、写教程和帮助帖,后来又创作了 YSF。YSF 大概是我最成功的项目了,因为它做到了所有人都想要的事。它本质上就是一个改进版的服务端——人们总是渴望新版本带来修复和新功能,功能方面已经有其他插件(包括 YSI 插件)覆盖了,所以我选择了专注于修复这条路(以及一些小功能——显然正是这些小功能引发了这次麻烦)。
总之,Kye 和我一般都各自行事、互不干扰(至少我是这么以为的),有一段时间相安无事。直到 2009 年 1 月 19 日,我收到了 Kye 的消息,说他不太喜欢我在 YSF 里做的事。经过一番交涉,才明白他的顾虑在于:YSF 可能与未来版本的脚本产生兼容性问题(这话从他嘴里说出来真是讽刺——我当初就反对过他把默认运行方式改掉,以及 0.3 版本里删除对象的决定,但他否决了我;对象最后是改成了 IPL 文件的形式,而非完全删除,但也没有了 per-player objects,至少我最后听到的消息是这样)。此外,他还质疑 YSF 里有 SA:MP 服务端的源代码——他举的例子是 functions.cpp 里的 IsNickInvalid 函数。这个函数其实不是照抄的,但我承认看起来确实很像,毕竟它做的事完全一样,而且也不是什么复杂的函数,我懒得争,就直接把它彻底重写了。在我指出 YSF 并不会造成兼容性问题(恰恰相反,它让脚本对未来版本的兼容性更好),并同意日后不再使用 SA:MP 源代码(尽管我此前一直在刻意避免这样做)之后,他让步了。
第二天我上论坛,发现自己已经被封了……
原来在我们达成共识之后,他又发现了 SetServerRule 这个功能,意识到它可以用来修改只读控制台变量(比如服务器版本号),从而让人能够违反 SA:MP 服务条款第(g)条:'你不得人为地增加玩家数量,或向 SA:MP 查询机制提供虚假信息。'
我解释说,我本人根本没有这样做,这个功能的设计初衷是好的,只是不幸被人滥用了。我甚至想过反问他:他提供论坛和 IRC 频道,同样也给人们提供了违反条款(c)(d)条的方式,按他的逻辑,他也应该被封号——但我懒得说了。封号通知的原文是:
'抱歉 ssǝן‾ʎ,你已被禁止使用本论坛!滥用前开发者身份。入侵服务器,并为他人违反服务条款提供手段。'
所谓'滥用',是指我用它改善了所有人的游戏体验;所谓'入侵服务器',是指我修复了它;所谓'提供手段',意思是人们可以拿我提供的工具做坏事——但这不代表我支持或认可这种行为,这只是一个有用工具所带来的无奈副作用。
Kye 说 YSF 的事已经覆水难收,他只能发布新版本来屏蔽它,伤害已经造成,它已经广泛传播,人们正在用它黑自己的服务器,没有人能阻止,所以我必须被封。我回应说,如果我没被封,我本来可以发布最新版本,在修复拾取物 bug 的同时,加入对规则的只读标志检查——虽然这无法彻底解决问题,但能大幅减少滥用,因为几乎没有人会主动把它改回旧方式,大多数人会为了这个重要修复而升级。说到这里,他就不再回复我了(我觉得他是意识到自己断掉了快速、高效解决这件事的唯一合理途径,然后不想认账了——如果不是这样,我欢迎他来回应)。
总之,我决定把当时正在写的那段代码(YSI 的示例脚本)收个尾,毕竟我还是喜欢使用 YSI 的那些人,上传最终版本,然后就此结束。
所以,截至目前:YSI 1.0、YSF 1.0、YSI2 1.0、YPI 1.0,到此为止,我不干了。
附言:Kye 正在研究一种过滤"修改版本服务端"并将其列入黑名单的机制——我可能会发布一个不自动修改版本号的 YSF 版本,这样用它的人就不会因为我的失误被连坐;请留意这里的动态(我可能也会顺带加入拾取物修复,但不会保留只读变量相关的功能)。
附言二:IRC 才是王道,我还活跃在 GTANet 的大量频道里,包括 #YSI 和 #Y_Less,欢迎来找我。
附言三:不知道 MTA 的源代码长什么样……"
——Y_Less,2009年1月,TMS Forums
这篇声明在社区引发了强烈反响。数日后,经社区压力,Y_Less 得以解封,YSF 在不修改只读变量的前提下被允许继续使用。从表面上看,风波平息了。
但那句"附言三"——"不知道 MTA 的源代码长什么样"——如今读来,已不再只是一句无心之语。
第三卷:停滞、管控与分歧(2014–2019)
第八章 0.3.7 之后的沉寂(含源码泄漏事件)
2015 年 5 月 1 日,SA:MP 0.3.7 发布。此后,Kalcor 在新功能层面的开发近乎陷入停滞。
2018 年前后,Kalcor 发布了 SA-MP 0.3.DL——一个支持服务端向玩家客户端推送自定义模型(Download Content)的版本。这是社区长期以来最渴望的功能之一,玩家们可以在 SA:MP 服务器上看到服务器自定义的车辆、皮肤、物件模型,而不再局限于原版游戏资产。
然而 0.3.DL 的推出并不顺利:安全问题(恶意服务器可能推送损坏的模型文件危害客户端)和与旧版 0.3.7 的兼容性问题,使得相当一部分服务器拒绝迁移。SA:MP 社区自此一分为二,0.3.7 阵营与 0.3.DL 阵营长期并存。
这种分裂令社区感到困惑与沮丧。有人多次在论坛呼吁将两个版本合并,但得不到任何回应。
源码泄漏事件(2010 年起源,2018 年爆发)
在 0.3.DL 引发社区分裂的同一时期,另一条隐患也终于浮出水面。
2018 年 11 月 15 日,SA:MP 官方论坛上出现了一篇由用户 Kshishtof 发起的帖子,标题是《SA-MP 源代码泄漏》。帖子指向一个名为 rw-mp.net 的项目,该项目被认为是基于泄漏的 SA:MP 源代码构建的独立多人联机分支。
帖子引发了社区的激烈讨论。一部分人对 rw-mp 持同情甚至欣赏的态度,认为它至少在做 SA:MP 不再愿意做的事;另一部分人则斥之为对原作的窃取与侮辱。
2018 年 11 月 16 日,Kalcor 亲自在帖子中出现,发表了两段回应。第一段揭示了这批泄漏源码的来历:
引用:"SA:MP 的源代码于 2010 年被一名法国人从我们的服务器上黑掉,目的是为 IV:MP 这个 mod 服务。我记录并上报了这次入侵。
GitHub 上大约有十个副本。我一直没有把它们下架,是因为提交 DMCA 申诉需要将我的真实姓名录入 GitHub 的公开 DMCA 数据库,否则就得花钱请律师。"
第二段话语气更为强硬,是一封事实上的法律警告:
引用:"0.2.5 的源代码是通过计算机入侵从我们的服务器上盗取的。任何基于该代码发布软件的人,都涉嫌与我们作对的刑事共谋。
SA:MP 所使用的 RakNet 版本并非开源,而是通过付费许可证使用的。BASS 音频库同样是付费许可证使用的。整个游戏 mod 社区正在被那些删除原始版权信息、将他人成果冒充自己作品重新发布的蠢货所伤害。
我给他们几天时间关闭并停止任何与 SA:MP 相关的活动。"
——Kalcor,SA:MP 官方论坛,2018 年 11 月 16 日 原帖:https://sampforum.blast.hk/showthread.php?tid=660866
这段声明揭示了几个此前从未公开过的重要事实:
其一,SA:MP 源码泄漏的根源远早于 2018 年——2010 年就已经发生了一次有据可查的服务器入侵事件,是一名法国黑客为了支持 IV:MP(GTA IV 的多人 mod)而实施的。这与 Kalcor 在 2019 年 pawn.wiki 文章中所说的"R* 给 MTA 的代码被用于攻击 SA:MP"形成了完整的脉络——他所遭受的代码安全威胁,来自不止一个方向。
其二,Kalcor 之所以始终不对 GitHub 上的泄漏副本提交 DMCA,并非不知情或不在意,而是出于一个极具讽刺意味的现实顾虑:提交 DMCA 需要将他的真实姓名公开录入数据库,而他不愿意这样做。源码在网上流传的同时,他本人却因为隐私顾虑而放任不管。
这次事件在社区中引发了新一轮关于"Kalcor 应不应该直接将 SA:MP 开源"的讨论。部分社区成员认为,既然源码已经通过非法途径广泛流传,不如主动开源,至少可以让社区以合法的方式参与改进。这个声音 Kalcor 听到了,但他的答案,在两年后的 pawn.wiki 那篇文章里已经说得很清楚。
第九章 Kalcor 眼中的历史:MTA 的旧账、R\* 的源码,与闭源的真实理由
编年史至此已经写了许多关于 Kalcor 的事——他的决策、他的封号、他的沉默——却始终没有给他一个真正完整的出场。他是从哪里来的?他为什么那样做?他拒绝开源的理由究竟是什么?
2019 年 10 月 25 日,就在 Kalcor 于 SA:MP 官方论坛发布某条帖子后不久,俄语 Pawn 开发者社区 pawn.wiki 上出现了一篇由用户 m1n1vv 整理、转述并翻译的长文,标题是《SA-MP 0.3.9 与 Kalcor 的新故事》。这篇文章完整记录了 Kalcor 这篇帖子的核心内容——那是迄今为止他对自己技术履历与闭源立场最完整、最直接的一次亲口陈述。
以下是 Kalcor 在那篇帖子中所说的全部内容,经 m1n1vv 翻译并整理,原文以俄文发布于 pawn.wiki:
引用:那么,请允许我给你们讲一段历史。结论由你们自己得出。
2003 年,在 MTA 的 IRC 频道上待了一周之后,我被邀请加入 MTA:VC(Vice City Multiplayer)的开发。几个月后,我晋升为 MTA 的首席开发者。以下是一张截图,记录了我和 Si|ent 一起尝试让 MTA:VC 中的船只同步正常工作的画面,日期是 2003年12月31日。
我是 MTA:Blue 的少数开发者之一——这是 MTA 针对罪恶都市的新版本。那时候除我之外,大多数人都被现实生活拖住了。我开发了玩家上下文切换同步系统,并引入了 RakNet 库。同样的系统至今仍被 SA-MP 和 MTA:SA 所使用。
这种处境让我感到沮丧——一周又一周过去,只有我一个人在提交代码,而论坛上却有一堆"开发者"在大谈特谈一切将会如何运作。
于是我离开了 MTA 的开发。在我离开期间,另一个叫 eAi 的人加入了项目,开始研究我的代码,试图弄清楚它是怎么工作的。至少有人在做事了——他的方式是去主动联系各方,于是他通过 ICQ 找到了我,还找到了 R(Rockstar Games)开发者的电子邮件地址,并与他们取得了联系。*
那时候我对 GTA 多人联机并不感兴趣。我有一个乐队,还参与了 ipodlinux 等项目,但 eAi 似乎非常热衷于推动 MTA:Blue 取得进展。于是我重新加入了项目,持续了几个月。
以下是 2004年底 MTA:Blue 的公开宣传页面存档。
正是这件事导致了我和 MTA 其他开发者之间的决裂:我想要构建一个服务端系统——就像 VC-MP 的 ini 脚本系统,以及 SA-MP 中基于 Pawn 脚本的服务端系统;而 MTA 的开发者们则想要客户端扩展,就像那个宣传页面上描述的那样。
2005年初,我再次离开了 MTA。就在那段时间,R North 在 PS2 上发布了 GTA:SA,并正在开发 PC 版本。eAi 与 R 的技术总监有联系,还通过 ICQ 向我炫耀说,他拿到了大量 GTA:SA 源代码的头文件。我注意到,MTA 的开发者们似乎打算放弃罪恶都市版的 MTA:Blue,转战 GTA:SA。
不相信 MTA 及其开发能力,2005年中,我开始独立开发自己的多人联机。我发布了 VC-MP,基于玩家上下文切换和 RakNet。
随后,我与 jax、spooky 以及其他一些人一起开发了 SA-MP。它远比之前的 mod 庞大得多。我们与 GTANet/GTAForums——最大的 GTA 非官方粉丝站——有着较为密切的联系,这使我们获得了更大的知名度。
到 2008 年,SA-MP 的玩家数量是 MTA 的 10 到 20 倍。MTA 的开发者们感到在这种竞争环境下已无法与之抗衡,于是他们公开了自己的源代码。MTA 的开发者们从未询问过我或任何前任开发者,是否可以公开这些源代码。他们声称所有东西都已经被重写了,但事实并非完全如此。时至今日,MTA:SA 中仍然有我的代码。我没有费心去追究他们,因为他们看起来太蠢了——但我猜,这里那些呼吁公开 SA-MP 源代码的人,很可能是受到了 MTA 的启发,尽管他们或许不应该这样。
而这就是现在正在发生的事:我为 MTA 开发的"游戏"代码,以及 R\ 给 MTA 的源代码,正在被移植进 mod\so\beit——一款流行的 SA-MP 外挂工具。*
你们知道这意味着什么吗?R\ 给 MTA 的那部分源代码,有相当大一部分被用来——攻击 SA-MP!*
从一小批相当专业的 MTA 开发者起步,这件事已经演变成了极度肮脏的东西。那些想要摧毁 SA-MP 的人,对于是否违法毫不在意。
看起来,任何被公开的源代码都可能被用来攻击 SA-MP——尽管它本可以被用于提供有价值的更新。
公开 SA-MP 源代码这个决定,归根结底取决于:我们是否仍然处于这种充满敌意的环境中——人们在攻击我、想要攻击 SA-MP——还是这一切已经成为过去。我想说,这取决于 mod 的规模以及对外挂工具的需求程度。封闭源代码让我可以在每次发布时重新洗牌所有内部结构,使大量外挂工具失效。SA-MP 的许多"安全"更新,实质上就是我在重新排列数据,以清除现有的外挂工具。
最终,SA-MP 将会走到这样一个时刻:所有人都彼此友善和尊重,外挂与欺诈变得罕见——但届时玩家数量将极为稀少,我们中的大多数人也已不在了。
——Kalcor,2019年10月,SA:MP 官方论坛
(由 m1n1vv 转述并整理,经 Типичный Скриптер 译为俄文,发布于 pawn.wiki,2019年10月25日)
这篇陈述,是理解 Kalcor 其人与其决策的关键文献。
它第一次完整地揭示了他的来历:他不是凭空出现的 SA:MP 创始人,而是一个从 MTA 内部出走的人。2003 年至 2005 年间,他在 MTA 团队中担任首席开发者,亲手构建了玩家同步系统与 RakNet 架构——而他愤而离开,正是因为他坚持的"服务端脚本系统"方向遭到其他成员的反对。SA:MP 的诞生,某种程度上是一次对 MTA 的报复性创业。
它也第一次给出了 Kalcor 拒绝开源的真实逻辑:他不是在藏私,也不是单纯的控制欲作祟——在他看来,闭源是他对抗外挂工具的核心武器。他承认 SA:MP 的许多所谓"安全更新",实质只是重新排列内部数据结构,使依赖固定内存地址的外挂工具在新版本中失效。一旦开源,这条防线便彻底坍塌。
而他与 MTA 之间的积怨,在这篇文章里也首次有了完整呈现:他认为 MTA 在开源自己的代码库时,从未征得他这位最初作者的同意;更令他愤怒的是,他随后发现,R\* 曾向 MTA 提供的部分 GTA:SA 源代码头文件,辗转流入了 SA:MP 外挂工具的制作者手中——成为攻击 SA:MP 的武器。这份偏执与防御,贯穿了他此后对待社区和开源请求的所有态度。
这篇发布于 2019 年 10 月的文章,距 Kalcor 宣告放弃 SA:MP 仅剩不到一个月。这是他在离开之前,最后一次试图解释自己。
|
|
|
|
| [考古] openmp 常见问题解答 | 2020年4月 |
|
发布者: 小鸟unsigned - 03-24-2026, 04:55 PM - 板块: 综合讨论
- 暂无回复
|
 |
GTA SA 和 SA:MP 中的漏洞会被修复吗?
会的!
你也可以向我们反馈我们尚未知晓的 bug:github.com/openmultiplayer/samp-bugs/
或在论坛提交:burgershot.gg/showthread.php?tid=99
open.mp 会支持哪些 SA:MP 版本?包括 0.3DL 吗?
我们不会支持 0.3.7 以下的版本,但你可以自行实现(这正是开源的魅力所在)。
如果大多数服务器都尝试使用 0.3DL,我们会跟进支持。我们也做过调查,多数人支持 0.3DL,所以这件事大概率会做,但不会出现在客户端的前几个版本中。
open.mp 客户端会有固定的更新周期吗?
不会。固定周期需要一支流程成熟的大型团队,而我们目前是一支志愿者队伍。
也许情况将来会有所改变,我们不敢打包票。代码开源之后,你们可以随时关注项目进展。我们会尽一切努力在版本更新之间保持与社区的沟通。
我们可以提交新功能建议吗?
我们非常欢迎社区参与,你可以在这里留下你的建议:burgershot.gg/forumdisplay.php?fid=42&page=6
当然,我们无法保证每个想法都会被采纳和实现。但由于代码是开源的,你随时可以自己动手实现。
会有反作弊系统吗?
会有。
open.mp 会继续使用 Pawn 吗?可以使用其他语言吗?
所有的开发工作、测试和文档均以 Pawn 为基础完成。不过,我们提供了 C API,允许任何人在此之上实现其他语言的支持。
我们也讨论过引入一些半官方的语言模块,但 Pawn 依然是主要且受官方支持的脚本语言。
SA:MP 的插件能在 open.mp 中使用吗?
"插件"这个词目前用来指代旧式插件——即那些为 SA-MP 编写的插件(例如 sscanf、MySQL 等)。open.mp 提供了加载这些现有插件的 API。
我们对新式插件的称呼是"模块"——无论是外部插件还是内部代码,只要使用 open.mp 更新、更强大的 API,统称为模块。
旧式插件仍然可以正常运行,并会持续得到支持,除非它们依赖内存黑客手段(例如 YSF),因为那类插件依赖特定的内存地址和数据结构,而这些在 open.mp 服务端上是完全不同的。不过,大多数老式内存黑客当初存在的意义,恰恰是为了补全一些功能——而这些功能将直接内置于 open.mp,所以那些插件本来也就用不着了。
数据库功能只能用 SQLite,还是也可以用 MySQL?
默认的数据库功能与以前一样,仍然是 SQLite。但如前所述,open.mp 提供了加载现有插件的 API,因此数据库方面无需在内部做任何改动——通过现有插件依然可以实现。
第三方模块在 open.mp 中是完全受支持的"一等公民"——无论是回调还是原生函数,其可用性与内部代码完全一致,因此将功能放入外部模块不存在任何劣势。
HUD、体力值、小游戏(台球、健身房等)、车辆选项、路线指示、SA:MP+ 功能等会有更多自定义选项吗?会支持更灵活的图形界面,而不是像 SA:MP 那样只能用有限的文字绘制吗?CEF 呢?这些内容会如何保障安全?
关于这个话题我们收到了太多提问,请先去泡杯茶——这个回答会有点长。
我们的开发团队里有 Hual(SA:MP+ 的作者),未来会带来什么,谁知道呢??
SA:MP 与 MTA 最本质的区别,其实在名字里就说得很清楚了——SA:MP 就是字面意思上的"圣安地列斯多人游戏",是 GTA 圣安地列斯这款游戏的多人版本;而 MTA 则是一个架设在这个世界观之上、更偏向通用平台的存在。
SA:MP 在加入新功能时,始终紧扣原版游戏的体验感,尽管走出框架并非不可能,但整体风格一直非常保守。
我们之所以长期坚守 SA:MP,正是因为认同这种态度。但我们希望拥有更多一点的自由度(这也是我们另起炉灶的原因),同时又不想走得太远。我们会引入一些新功能,其中有些在原版游戏中并不存在,但我们仍然会审慎地权衡每个功能带来的影响。
让对话框稍微宽一点,算是突破 GTA 正统边界的疯狂改动吗?大概不算。
加入 CEF 是否走得太远了?是的,这不会发生。?♂️
平衡点就在两者之间。
话虽如此,玩家们向来能在有限的支持下迸发出令人惊叹的创造力,我们相信这一点不会改变。我们会始终鼓励玩家和开发者尽情发挥创意——这也是我们选择开源的原因之一,我们甚至会在这方面提供帮助,但这不会是我们的核心任务。
我们最主要的顾虑集中在客户端层面。我们不希望服务器主可以向玩家分发任何具有潜在危害的内容。圣安地列斯本身并非为多人游戏或 mod 而设计,许多文件加载器对损坏或恶意文件缺乏防护——当初根本没有这方面的需求。这正是 SA:MP 在引入自定义模型时遇到的难题,对我们来说同样如此。
每当我们要对游戏的某个部分进行自定义,都必须先确保对应的文件格式和加载器不存在可利用的漏洞。我们可以确认,模型和脚本已经出现过被攻击的案例,其他部分可能也已经被攻击,而一旦我们开始允许服务器上传任意文件,被攻击几乎是必然的。
至于资源保护——坦率地说,这在根本上是无法实现的。要让客户端显示一个自定义模型,就必须把这个模型文件传给它。文件一旦传到客户端,它就在对方的电脑上了,对方拿它怎么做,我们管不了。有些方法可以让盗取变得麻烦一些,但没有哪种方法是绝对可靠的。网络上虽然存在 DRM、安全隔离区等手段,但要使用这些技术,就必须牺牲开源原则。可以参考一下 EME 标准以及 Firefox 中内嵌闭源二进制文件所引发的争议。
NPC 会有更好的支持吗?
这不是我们当前的优先事项,但从客户端角度来看,NPC 本质上与普通玩家没有区别。我们至少希望 open.mp 的 NPC 功能能够对标 FCNPC 插件的水平,这是未来版本中我们可以认真考虑的方向。
你们说 open.mp 移除了所有限制——这具体是怎么实现的?它像 streamer 插件一样吗?
原理类似,但与 streamer 相比集成度更高,因此我们同样可以对载具和玩家进行流式处理,并能以远超插件能力的方式操作本地 ID。
玩家可以被附加到物体上吗?
可以,我们已经实现了这个功能。
open.mp 完成 SA:MP 功能的复现之后,接下来会新增什么?
我们很喜欢聊未来的计划——持续关注我们就好,你们会看到的。
可以用 SA:MP 客户端连接 open.mp 服务器吗?
SA:MP 是通过逆向工程对第三方程序进行交互的 mod,open.mp 也是如此,我们的法律立场与 SA:MP 完全相同。我们没有使用任何 SA:MP 的泄露源代码——这一点已经反复强调,代码开源之后,社区自然会看得清清楚楚。
新版 open.mp 客户端最大的挑战是什么?
把它写出来!
五年后,你们认为 open.mp 会是什么样子?
不必担心这个,我们有很多长远的想法。?
五年后,你们认为 SA-MP 会是什么样子?
那是 SA-MP 团队的事,不是我们该操心的——在这个问题上我们没有立场去猜测。
你们有没有想过放弃 open.mp?
有过,因为我们也是普通人。
最后,也是被问得最多的问题:
我们什么时候才能看到 open.mp?
等它准备好了。???
祝大家接下来的一周愉快!
带着满满的爱,
open.mp 团队 ?
|
|
|
|
| [考古]MTA 团队与 open.mp 团队早期就潜在合作可能性展开的公开对话 | 2019年5月 |
|
发布者: 小鸟unsigned - 03-24-2026, 04:53 PM - 板块: 综合讨论
- 暂无回复
|
 |
Jusonex,MTA 团队代表:
你好,
在正式开始之前,先允许我做个自我介绍。我是 MTA 团队的成员,长期深度参与多人游戏模组的开发工作。本帖内容也已获得团队其他成员的认可。
过去这几天,我们一直在默默关注 SA-MP 的近况,以及你们测试团队的动向。我们刻意等待了一段时间,让局势稍微平息,这也是我们迟迟没有表态的原因。
在我们看来,眼下的局面对 MTA:SA 和 SA-MP 来说,恰恰是一次难得的相互靠拢的机会。我们理解你们对现状的不满,但我们认为,为《GTA 圣安地列斯》这款已有相当历史的游戏再从零搭建一个多人游戏项目,似乎并不是最明智的选择。我们对圣安地列斯的热爱,与你们一样深厚。尽管近来在线玩家数量基本保持稳定,但不可否认的是,吸引新玩家的难度正在与日俱增——尤其考虑到玩家群体结构的变化。
因此,我们诚邀你们加入 MTA 社区。从技术层面来看,我们认为最务实的方案是实现从 SA-MP 到 MTA 的兼容层,这样也能帮助你们更快推进前期更新的发布。
说几个数据:过去三年,我们发布了四个新版本,并持续推出大量迭代更新和反作弊升级;而 SA-MP 的最后一次官方发布,距今已超过四年(依据 sa-mp.com 官方数据)。
除少数例外情况,MTA 在技术上已基本能够"模拟"SA-MP 的全部功能。我们相信,凭借你们作为资深开发者的经验,双方完全可以共同实现客户端兼容。
此外,我们已有一个名为 amx 的资源(https://github.com/multitheftauto/mtasa-resources/tree/master/%5Bgamemodes%5D/%5Bamx%5D),可在 MTA 内部运行 Pawn 虚拟机。该资源目前版本较旧,可能需要重构,但核心功能依然可用。
我们相信这将是一个对双方都有利的合作,也是两个社区难得的机遇——不仅能汇聚各自优秀的开发者和新鲜想法,还能为玩家带来真正丰富的游戏体验,甚至可能吸引新玩家(例如 MTA 的粉丝群体)加入,并催生一批概念全新的脚本功能。
如果你们对潜在问题有任何顾虑,欢迎随时与我们深入探讨。
谨致问候,代表 MTA 团队。
JustMichael,论坛跟进者:
我们需要在团队内部充分讨论之后,才能就该提案给出正式回应。这绝非一个小提案,我们需要时间消化并核实帖子中提及的所有信息。因此,请不要将这条回复理解为拒绝——我们绝对没有在拖延,会尽快给出正式答复。另外,请始终将 J0sh 的名字写作"J0sh……"。
Jusonex,MTA 团队代表:
引用:BloodMaster 写道:「SA-MP 保留了圣安地列斯原汁原味的氛围和外观,而 MTA 则是一个可以高度自定义的引擎——这正是 SA-MP 吸引更多玩家的根本原因。」
这个观点我之前也看到过几次,确实触及了一个重要方面。MTA 确实拥有某种"可自由改造的自定义引擎",但我个人并不认为这是一个负面特质。毕竟,没有人强迫任何人把游戏做成与原版圣安地列斯截然不同的样子——这完全是开发者自己的选择。况且,随着功能的不断叠加,你们迟早也会面临类似的情况。
引用:iReal Worlds 写道:「Lua……认真的吗?」
依我个人之见,Lua 是一门非常出色的多范式语言,同时支持命令式、面向对象乃至部分函数式编程风格。更重要的是,它在设计之初就以"嵌入其他应用程序"为核心目标,这在客户端脚本的安全执行方面具有显著优势,是其他语言难以比拟的。
此外,我还想提一个近期由 MTA 社区打造的有趣项目:https://mta-slipe.com/。它本质上是将 C# 引入到 Lua 环境中。
引用:michael@belgium 写道:「想象一下所有人都切换到 Lua……」
这并不是我的提议。我们谈论的是兼容性方案——让 SA-MP 的模组和插件(用 Pawn 编写)无需任何修改,即可直接在 MTA 中运行。如果 open.mp 的开发者计划支持更多脚本语言,我们也可以将 Pawn 作为 MTA 的附加语言引入(包括对 SA-MP API 脚本的支持)。
MyU,open.mp 开发者:
首先声明:以下内容纯属个人观点,与 open.mp 项目及团队立场无关。
事实上,已经有一些人指出,MTA 和 SA-MP 本质上是两种完全不同的东西——确实如此,差异相当显著。MTA 走的是不断创新的路线,而 SA-MP 则始终"守着老传统";正因为长期没有更新、也不理会社区的功能需求,才会是今天这个局面。
我个人两款都玩过(MTA 是大约两三年前,当时 OOP Lua 刚加入不久,玩了一两个月)。玩了一段时间后,MTA 对我来说变得有些"吃不消"——那时候我的电脑配置比较低,加上遇到了一些 CEF 相关的问题,虽然我相信这些问题现在应该都已经解决了。
SA-MP 相当"轻量":一个压缩包、一个 samp.dll、一个启动器,再加一两个文件,就全了。而 MTA:SA 则捆绑了 CEF 文件、CGUI 文件、本地化文件等一大堆内容。我个人认为,把这两个多人游戏合并在一起是行不通的——它们太不一样了,目标也各不相同。SA-MP 里安装 mod 极其简单,丢进游戏文件夹就能用;MTA:SA 则更像是一个经过精心打磨、功能完整的多人游戏模组,但玩家有自己的偏好,这无可厚非。
简单总结一下:
- SA-MP → 轻量、易上手,但在某些方面存在局限;
- MTA:SA → 体量较重,但功能全面,有时略显复杂,稳定性却强于 SA-MP(玩家们甚至会利用 MTA:SA 的崩溃报告来排查问题——这本身就说明很多问题)。
不过我认为,玩家并不希望社区就此一分为二、各走各的路。竞争本身,以及由此带来的优胜劣汰,对整体发展是有益的。
Jusonex,MTA 团队代表(回复 MyU):
性能问题在绝大多数情况下都源于脚本编写质量低下(尤其是客户端脚本写得糟糕)。"能力越大,责任越大"这句话在这里再合适不过了,虽然这听起来有点无奈。
Sasino97,open.mp 软件开发者:
我认为加入 MTA 最大的好处,是可以直接接触到庞大的现有玩家群体,以及 MTA 客户端本身极为强大的功能上限。至于缺点嘛……他们的论坛实在是太丑了,抱歉直说。
不过,我个人很认可 open.mp 这个项目的理念,所以我觉得两边(MTA 和 open.mp)还是应该作为独立项目,各自继续发展下去。
|
|
|
|
| 服务器开发 精选资源清单 |
|
发布者: 小鸟unsigned - 03-22-2026, 12:24 AM - 板块: 发布
- 暂无回复
|
 |
为 SA-MP 开发精选的实用工具、库、游戏模式、滤镜脚本和插件列表。
工具
库
命令处理器
插件
客户端-服务器插件
加密插件
GDK/SDK
现在,你可以使用 open.mp 在不借助任何插件的情况下,用 Pawn 以外的语言编写脚本。请参阅 这篇博客文章。
游戏模式
滤镜脚本
|
|
|
|
| [教程] 枚举器 enum 详细讲解 原文作者: iPLEOMAX |
|
发布者: 小鸟unsigned - 03-22-2026, 12:11 AM - 板块: 教程
- 暂无回复
|
 |
枚举器
原文作者: iPLEOMAX
关于枚举,有些细节很多脚本作者并不清楚。
很多人都喜欢在脚本里用枚举来存玩家数据、车辆数据、房屋数据之类的,尤其是用户信息这块。
一个很典型的写法是这样的:
代码:
enum E_PLAYER_INFO
{
SCORE,
MONEY,
KILLS,
DEATHS
};
new pInfo[MAX_PLAYERS][E_PLAYER_INFO];
用起来大概是这样:
代码:
public OnPlayerDeath(playerid, killerid, reason)
{
pInfo[playerid][DEATHS]++;
if(IsPlayerConnected(killerid) && killerid != playerid)
pInfo[killerid][KILLS]++;
return 1;
}
这段应该不难理解。
现在我们看另一个例子:
代码:
enum E_PLAYER_INFO
{
SCORE,
MONEY = 9,
KILLS = 5,
DEATHS = 56
};
new pInfo[MAX_PLAYERS][E_PLAYER_INFO];
printf("%i | %i | %i | %i", pInfo[0][SCORE], pInfo[0][MONEY], pInfo[0][KILLS], pInfo[0][DEATHS]);
你可能会觉得输出是 0 | 9 | 5 | 56,对吧?
如果你这么想,那就错了。实际上输出是 0 | 0 | 0 | 0。
你可能会以为枚举是用来存储数据的,但事实并非如此。我在枚举里写了 MONEY = 9,然后在 printf 里用了 pInfo[0][MONEY],但编译器其实把它当成 pInfo[0][9] 来处理,而不是什么 E_PLAYER_INFO:MONEY。也就是说,MONEY 在这里只是作为一个索引来用的。
所以枚举本质上根本不是变量,它其实就是一组常量,只不过帮你的数组索引起了个好记的名字罢了。
好,我们再来深入一点,应该能让你理解得更清楚。
举个例子:
代码:
const e_CAR1 = 0;
const e_CAR2 = 1;
const e_CAR3 = 2;
new MyCars[3];
main()
{
MyCars[e_CAR1] = 520;
MyCars[e_CAR2] = 458;
MyCars[e_CAR3] = 411;
}
现在我们用枚举重写一下:
代码:
enum e_TEST
{
e_CAR1,
e_CAR2,
e_CAR3
};
new MyCars[e_TEST];
main()
{
MyCars[e_CAR1] = 520;
MyCars[e_CAR2] = 458;
MyCars[e_CAR3] = 411;
}
这两段代码都能正常编译,做的事情也完全一样。你看出区别了吗?其实就只是代码写法不同而已。
这个例子告诉我们,枚举干的事情和常量是一样的。不过枚举有些地方比常量或者宏定义(const e_CAR1 = 0; 或者 #define e_CAR1 0)更好用,具体差异我们后面再说。
首先你需要明白枚举的结构:
代码:
enum TEST
{
Abc,
Def,
Ghi,
};
Abc 是一个值为 0 的常量(差不多等于 const Abc = 0; 或 #define Abc 0),Def 是值为 1 的常量,Ghi 是值为 2 的常量。
预编译器会自动给这些常量赋值,从 0 开始一直往上排。而用常量的时候,你得自己手动赋值。枚举则是自动帮你排好的。
再看个例子:
代码:
enum TEST
{
e_ONE, // 自动得到 0
e_TWO, // 得到 1
e_THREE, // 得到 2
e_FOUR = 12,// 这里手动赋了 12,所以不再是 3 了
e_FIVE, // 变成 13,因为上一个值是 12
e_SIX // 变成 14,上一个值是 13
}
看到了吧,如果你手动给某个枚举项赋了值,后面的项就会在此基础上依次加 1。
再来看看更复杂的情况:
代码:
enum DATA
{
INT, // 得到 0
STRING[10], // 得到 1,但因为是数组,一个位置不够,它会占 10 个位置
// 所以实际占用的索引是 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
INT2, // 得到 11,因为 STRING 最后一位是 10
STRING2[10] // 从 12 开始,一直到 22,需要 10 个位置
};
运行下面的代码:
代码:
printf("%i", _:DATA);
结果输出 22。
因为整个枚举的总大小是 22:
INT 占 1 个 + STRING 占 10 个 + INT2 占 1 个 + STRING2 占 10 个 = 22。
你可能会问,为什么我写 INT 是 1 而不是 0?因为这里我数的是占用了多少个位置,不是从哪个位置开始。如果问起始位置,那 INT 确实是 0。
一个大小为 22 的块,索引范围是 0 到 21,总共 22 个位置。
再试一下:
代码:
printf("%i", _:STRING);
输出 1。因为第一个位置(索引 0)被 INT 占了,所以 STRING 从索引 1 开始。
每个枚举项占用的位置是这样的:- INT:索引 0
- STRING:索引 1 到 10
- INT2:索引 11
- STRING2:索引 12 到 21
运行这段:
代码:
new Array[DATA];
printf("%i", sizeof Array);
结果也是 22,因为 DATA 的大小就是 22。
所以,这个数组实际上相当于:
代码:
Array[INT + STRING + INT2 + STRING2];
这行代码是编译不了的,只是帮你想明白结构而已。
也就是说,用枚举的时候,你其实是用这些枚举常量作为数组的索引。
另一个例子
代码:
enum TEST
{
SomeInteger = 124,
SomeString[12],
Float:SomeFloat
};
public OnFilterScriptInit()
{
new var_TEST[TEST];
var_TEST[SomeInteger] = 1337;
printf("1) 储存的值: %i, 常量: %i", var_TEST[SomeInteger], _:SomeInteger);
format(var_TEST[SomeString], 12, "Hey!");
printf("2) 储存的值: %s, 常量: %i", var_TEST[SomeString], _:SomeString);
var_TEST[SomeFloat] = 2054.124;
printf("3) 储存的值: %f, 常量: %i", var_TEST[SomeFloat], _:SomeFloat);
return true;
}
输出结果:
储存的值: 1337, 常量: 124
储存的值: Hey!, 常量: 125
储存的值: 2054.124023, 常量: 137
第一行,SomeInteger 的常量值是 124,我把 1337 存到了索引 124 的位置。
第二行,SomeString 虽然定义了长度为 12,但输出常量值时显示的是 125。因为 125 是这个字符串的起始索引,它占了 125 到 136 这 12 个位置。
第三行,浮点数正常显示,但 SomeFloat 的常量值是 137。你可能会想,它应该在 SomeString(125)后面,应该是 126 才对?但别忘了 SomeString[12] 是个数组,占了 12 个位置,所以 SomeFloat 的起始索引是 125 + 12 = 137。
一些额外的重要信息
枚举也可以作为标签使用:
代码:
enum E_MY_TAG
{
E_FIRST = 4,
E_SECOND = 2
}
然后可以这样写:
代码:
new E_MY_TAG:SomeVar;
或者:
代码:
new E_MY_TAG:MyVariable = E_FIRST; // 不会报错,因为 E_FIRST 本来就是 E_MY_TAG 的一部分
但是:
代码:
new data[E_MY_TAG];
data[E_FIRST] = 7; // 编译错误,索引越界
因为 E_MY_TAG 的大小是 3(枚举里最大索引是 2),而 E_FIRST 的值是 4,已经超出范围了。
匿名枚举:
代码:
enum // 没有名字
{
E_TEST[10] = 32,
E_VAR
};
main()
{
new data[E_TEST]; // E_TEST 的值是 32,不是 10
}
强标签和弱标签:
代码:
enum E_STRONG // 名字以大写字母开头,生成的是强标签
{
E_VAR = 64
};
main()
{
new Test = E_STRONG:E_VAR;
// 会报“标签不匹配”的警告,因为 Test 没有 E_STRONG 标签
}
代码:
enum e_WEAK // 名字以小写字母开头,生成的是弱标签
{
E_VAR = 64
};
main()
{
new Test = e_WEAK:E_VAR;
// 没有警告
}
枚举的大概内容就是这些了,这也是为什么我们更喜欢用枚举而不是直接写常量的原因。
感谢 Y_Less 的讲解。
|
|
|
|
| [教程] Pawn 中的压缩字符串 原文作者: Emmet_ |
|
发布者: 小鸟unsigned - 03-21-2026, 11:52 PM - 板块: 教程
- 暂无回复
|
 |
Pawn 中的压缩字符串
原文作者: Emmet_ 本篇教程仅为翻译搬运
引言
压缩字符串自 SA-MP 和 Pawn 诞生之初就已存在。然而,很多人并不了解压缩字符串以及它能节省多少内存!本教程将教你压缩字符串的基础知识,以及如何正确操作它们。
什么是压缩字符串?
压缩字符串是一种数组,它将数据存储在每个字节中,而不是像普通数组那样存储在每个单元格中。压缩字符串以小端序存储(即低位字节在前),并且只能容纳 0 到 255 的 ASCII 字符,超出这个范围的数值会绕回。
看这段代码:
代码: new string[5];
string[0] = 'a';
string[1] = 'b';
string[2] = 'c';
string[3] = 'd';
string[4] = '\0';
'a' 存储在单元格 0,'b' 存储在单元格 1,依此类推。一个单元格基本上占用 4 个字节,所以算一下,上面的字符串大约占用 20 个字节,每个字符占用 4 个字节的空间。
然而,使用下面的代码:
代码: new string[5 char];
string{0} = 'a';
string{1} = 'b';
string{2} = 'c';
string{3} = 'd';
string{4} = '\0';
'a' 存储在字节 0,'b' 存储在字节 1,依此类推。实际上,上面的字符串只占用 8 个字节,仅包含 2 个单元格!
你可能在想为什么这个字符串不是 5 个字节。使用 char 修饰符会自动将数组大小向上取整到最近的 4 的倍数(例如,1 变成 4,3 变成 4,5 变成 8,23 变成 24,依此类推)。
代码: // 这个数组占用 5 个单元格,共 20 个字节。
new string[5] = "abcd";
// 这个数组占用 2 个单元格,共 8 个字节。
new string[5 char] = !"abcd";
因此,使用压缩字符串可以节省 3 到 4 倍的内存!
适用场景
你可能认为压缩数组没什么用。如果这么想,那你就错了。压缩数组有很多用途,不仅仅是节省内存!
稀疏数组
首先,你可以阅读我关于稀疏数组的教程:
https://sampforum.blast.hk/showthread.php?tid=480439
稀疏数组就是那些大部分数据经常为空的数组。使用压缩数组可以轻松节省内存!
代码: #define MAX_ITEMS (64)
// 8,192 个单元格 = 32,768 字节!
new gData[MAX_ITEMS][128];
// 2,048 个字节 = 8,192 个单元格!
new gData[MAX_ITEMS][128 char];
不常使用的字符串
很多时候,你会把字符串保存到内存中,但很少使用它们(例如几乎不用)。对于这类数组,压缩数组非常适用。
大量数据存储
回到上面的“稀疏数组”部分,如果你需要存储大量数据,最好使用压缩字符串。
内存节省!
压缩数组能节省 4 倍的内存,既然可以用压缩数组,为什么还要用那些多占 4 倍内存的方式存储数据呢?
SA-MP 中的不支持情况
压缩字符串在纯 Pawn 中完美支持。但是,大多数 SA-MP 原生函数不支持压缩字符串,比如 format、GetPlayerName 等。
如果你打算使用压缩字符串,就必须依赖 strpack、strunpack 以及 string.inc 中的其他字符串函数。
格式化字符串
format 和 printf 函数不支持压缩字符串,因此你必须使用 strpack:
代码: new
string[128 char];
strpack(string, "Hello world!");
你也可以这样做:
代码: new
string[128 char],
temp[128]
;
strpack(string, "Emmet");
strunpack(temp, string);
format(temp, sizeof(temp), "%s likes to eat %s.", temp, "Big Macs");
strpack(string, temp);
支持压缩字符串的函数
string.inc 中的所有字符串函数都同时支持压缩数组和非压缩数组。
fread 和 valstr 函数也接受一个可选的 pack 参数来支持压缩数组,因此你不用担心它们是否能与压缩数组一起工作。
访问数据
在 Pawn 中,非压缩数组将数据存储在每个单元格中。而压缩数组将数据存储在每个字节中,这意味着你不能像访问非压缩数组那样使用方括号 [] 来访问和获取压缩数组中的数据。
代码: // 错误。
if (g_PackedString[0] != '\0')
{
g_PackedString[0] = 'h';
g_PackedString[1] = 'i';
}
// 正确!
if (g_PackedString{0} != '\0')
{
g_PackedString{0} = 'h';
g_PackedString{1} = 'i';
}
另外,要设置一个压缩字符串,你需要在字符串前加上感叹号,以表示这是压缩输入。
代码: // 错误。这会尝试将字符串按单元格存储,这不是我们想要的!
g_PackedString = "Hello world.";
// 正确!
g_PackedString = !"Hello world.";
|
|
|
|
| [修仙者篇] AMX 汇编文档 |
|
发布者: 小鸟unsigned - 03-21-2026, 11:19 PM - 板块: 教程
- 回复 (1)
|
 |
AMX 汇编
AMX 版本: 3.0
AMX 文件版本: 8
本文档是关于抽象机执行器(Abstract Machine eXecutor)汇编的非官方文档。本文档的内容可能不准确且可能随时更改。术语部分中定义的部分术语在官方手册中并未出现。它们是为了帮助读者理解而引入的辅助术语。
本文档的目标读者是首次接触汇编语言的程序员。他们应根据需要点击超链接以收集背景信息。高级用户可跳过部分章节或将本文档用作快速参考指南。
目录
先决条件
术语
<a name="term-amx"></a>抽象机执行器 (AMX):AMX 是我们感兴趣的抽象机(或虚拟机)。
<a name="term-amx-assembly"></a>AMX 汇编:AMX 的汇编语言。
<a name="term-amx-binary"></a>AMX 二进制:AMX 二进制文件是编译后生成的可执行文件(通常扩展名为 .amx)。术语 AMX 程序在某些上下文中可能指代 AMX 二进制文件。
<a name="term-amx-instance"></a>AMX 实例:每个加载的 AMX 二进制文件独立存在。加载后的程序称为 AMX 实例。术语 AMX 程序在某些上下文中可能指代 AMX 实例。
<a name="term-host-program"></a>宿主程序:嵌入 AMX 的程序称为宿主程序。以 San Andreas Multiplayer (SA-MP) 或 open.mp 为例,服务器就是宿主程序。
<a name="term-extension-module"></a>扩展模块:扩展模块是一个独立的模块,通常动态链接到宿主程序,提供额外的原生函数。扩展模块通常被称为插件。
基本概念
- ## <a name="concept-cell"></a> 单元格概念
Pawn 是一种无类型语言:没有数据类型。所有数据都作为原始二进制数据存储在一个单元格或一组单元格中。我们将使用有符号二进制补码表示法来传达一个或多个单元格的内容,而不是显式地用二进制写出来。
上述代码创建了两个独立的单元格,分别由 a 和 b 标识。单元格 a 将包含值 5,单元格 b 将包含值 65(字符 'A' 的 ASCII 等效值)。
为了弥补数据类型的缺失,Pawn 提供了为单元格添加标签的能力。这些标签仅仅是编译时的辅助工具,不会直接<sup>1</sup>出现在 AMX 二进制文件中。
代码: new Float:x = 5.0, Float:y = 10.0, Float:z;
z = x + y;
上述代码创建了三个带有标签的单元格,由 x、y 和 z 标识。它们分别被初始化为值 5.0、10.0 和 0.0(默认初始化为二进制零,也是 0.0),其二进制表示符合浮点数的正确表示。编译器会记住遇到的变量的标签,并确保在变量被使用时生成正确的代码 <sup>2</sup>。在上述例子中,编译器在处理第二行时,会生成正确的代码来执行两个浮点数 x 和 y 的加法。请记住,整数加法和浮点数加法的执行方式不同。编译器使用与单元格关联的标签来确定应对操作数执行哪种加法操作。
编译完成后,变量的标签信息会丢失。生成的 AMX 二进制文件将包含对 x 和 y 中存储的数据执行浮点数加法并将结果存储在 z 中的指令。
在运行时,浮点数加法会在不考虑脚本中 x 和 y 的标签原本是否为 Float 的情况下执行。
<a name="bc-cc-note-1"></a> [[1]](#bc-cc-note-1) Pawn 语言提供了 tagof 运算符,它返回与标签关联的编译时 id,该 id 可以被存储。编译器还会在 AMX 二进制文件的标签表中创建一个公开可访问的标签列表。
<a name="bc-cc-note-2"></a> [[2]](#bc-cc-note-2) Pawn 语言允许基于标签的运算符重载。编译器知道所有存在的运算符重载,并根据操作数的标签调用正确的重载。如果给定的操作数没有重载存在,编译器将默认使用无标签单元格的运算符(即整数算术)。
- ## <a name="concept-memory-addresses"></a> 内存地址
AMX 遵循平坦的字节可寻址内存模型,即 AMX 内存可以被认为是一个线性的字节集合
(平坦内存),每个字节都有一个唯一的地址(字节可寻址)。
上述代码创建了一个由五个单元格组成的数组,由 a 标识。这些单元格连续存储,即一个接一个地存储。假设单元格大小为 4 字节,如果存储 a[0] 的位置地址是 1000,那么存储 a[1] 的位置地址将是 1004。
代码: data: a[0] | a[1] | a[2] | a[3] | a[4]
address: 1000 1004 1008 1012 1016
所需知识:基地址,相对地址
所需知识:数组数据结构,元素标识符和寻址公式
另请参阅:字节序
- ## <a name="concept-stack-heap"></a> 栈和堆
每个 AMX 实例都包含一个内部 程序栈(也称为调用栈)和程序堆。这些是 AMX 程序中的内存区域,专用于一组任务。
栈负责:- 存储局部变量
- 存储函数参数
- 存储函数调用信息(例如参数个数)
- 提供临时存储
堆负责:- 为动态分配提供内存
- 存储作为引用/数组传递的默认参数
- 存储不是左值且作为可变参数传递的常量和表达式
###
代码: f(argc, ...) { }
main ()
{
new a, b, c[100];
f(1, 25);
}
在上面的代码片段中,变量 a、b、c 和常量 1 在栈上分配空间。常量 25 在堆上分配空间。引用和可变参数作为地址传递给函数。由于字面量 25 是一个常量且没有地址,因此在堆上分配了一个临时单元格来存储值 25。该单元格的地址被传递给函数。
- ## <a name="concept-instructions"></a> 指令
指令是离散的原子执行单元。大多数指令执行简单的任务,如基本算术。高级结构(如循环和条件语句)可以简化为一系列简单的指令。
例如,将值压入调用栈然后弹出的指令可能是:
汇编器为上述代码片段生成的二进制代码可能是:
push-opcode | 100 | pop-opcode
0x0013 0x0064 0x0014 或者可能是 0000 0000 0000 0000 0000 0000 0001 0011 0000 0000 0000 0000 0000 0000 0110 0100 0000 0000 0000 0000 0000 0000 0001 0100
是的,它只是一系列比特位,CPU(或者在我们这里是抽象机)知道如何解释这些比特位。当为物理硬件上的执行汇编时,这种表示被称为机器码(或原生码)。在为假想 CPU(如 AMX)上的执行汇编时,这种表示被称为 p-code。
另请参阅: 字节码
- ## <a name="concept-spc-vna"></a> 存储程序概念与冯·诺依曼体系结构
代码像数据一样驻留在内存中的想法被称为存储程序概念。这也意味着内存中的每条指令都有一个地址,就像数据一样。代码和数据都驻留在同一内存中的想法是冯·诺依曼体系结构的一个原则。这也意味着所有代码和数据共享一个地址空间。
AMX 使用一个公共内存来存储代码和数据。
- ## <a name="concept-hdc"></a> 头、数据段、代码段
可执行二进制文件及程序的内存结构根据其包含的内容被划分为逻辑上不同的区域。一个典型的二进制可执行文件至少包含一个头、一个代码段和一个数据段。头提供了程序本身的信息,例如程序入口点。
代码: HEADER
-------
CODE
-------
DATA
代码段以二进制形式(机器码/p-code)存储代码,而数据段存储数据,如全局变量和静态局部变量。
程序的内存结构通常不同。除了代码段和数据段外,它通常还包含程序栈和程序堆的区域。
代码: HEADER
-------
CODE
-------
DATA
-------
HEAP
|
STACK
AMX 二进制文件组织为三个部分:前缀、代码和数据(按顺序)。前缀部分包含有关 AMX 二进制文件的信息,例如代码和数据部分在二进制文件中的起始偏移量。代码部分存储 p-code,数据段存储全局变量和静态局部变量。
堆栈区域是在 AMX 二进制文件加载到内存后,使用前缀部分中的信息创建的。
另请参阅:内存分段,数据段,代码段,ELF 格式
- ## <a name="concept-registers"></a> 寄存器
了解处理器和内存位于不同的位置至关重要。
代码: |-------| |------------------|
| CPU | =============== | MEMORY |
|-------| BUS |------------------|
内存存储数据,CPU 对数据进行操作。对于每次操作,处理器都必须将操作数从内存中取出并临时存储在 CPU 内部。这些临时存储位置被称为寄存器。这些寄存器可以容纳少量数据,范围在几个字节(通常为 2、4 或 8 字节)。
假设处理器必须将两个变量 x 和 y 相加,并将结果存储在变量 z 中。一个典型的处理器会执行以下操作:
- 从内存中取出
x 的值(即从 x 的地址读取)并将其存储在一个寄存器中,比如 R1
- 从内存中取出
y 的值(即从 y 的地址读取)并将其存储在一个寄存器中,比如 R2
- 将
R1 和 R2 的内容相加,并将结果存储在某寄存器中,比如 R3
- 将
R3 的内容存储到 z 中(写入 z 的地址)
处理器包含不同类型的寄存器,用于不同目的。一些寄存器专用于特定任务,一些寄存器可用于任何目的。上例中使用的那类寄存器属于通用寄存器。
AMX 的寄存器集包括以下寄存器:- 主寄存器 (PRI):通用寄存器(常用作累加器寄存器)
- 辅助寄存器 (ALT):通用寄存器(常用作地址寄存器)
- 代码段寄存器 (COD):内存中代码段起始的绝对地址
- 数据段寄存器 (DAT):内存中数据段起始的绝对地址
- 当前指令指针 (CIP):下一条要执行指令的地址(相对于 COD 寄存器)
- 栈顶寄存器 (STP):栈顶的地址(相对于 DAT 寄存器)
- 栈指针寄存器 (STK):栈上当前位置的地址(相对于 DAT 寄存器)
- 帧指针寄存器 (FRM):栈中当前函数帧起始的地址(相对于 DAT 寄存器)(稍后解释)
- 堆指针 (HEA):堆顶的地址(相对于 DAT 寄存器)
在编写汇编代码时,地址通常不是绝对的;它们是相对于某个段或帧指针寄存器的。全局变量和字符串的地址相对于 DAT 寄存器。函数、指令和标签的地址相对于 COD 寄存器。这意味着变量或指令的绝对地址分别是相对地址加上 DAT 或 COD 寄存器的值。局部变量的地址相对于帧指针寄存器。这有点复杂,稍后会解释。
从现在开始,当我们讨论代码地址和数据地址时,除非明确说明,否则暗示这些地址分别相对于 COD 寄存器和 DAT 寄存器。
文件与内存布局
所有非调试 AMX 二进制文件按如下方式组织:
代码: START OF BINARY FILE
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
END OF BINARY FILE
- 前缀:包含程序的基本信息
- 代码:包含代码
- 数据:包含数据
注意:根据编译器的选项,前缀部分可能会被填充以使代码和数据部分对齐。
注意:启用调试编译的脚本的二进制映像将在末尾附加符号调试信息。更多详情请参阅 Pawn 实现者指南。
内存中的结构采用与二进制文件类似的结构,但额外包含一个栈和一个堆,这是使用前缀中的信息设置的。
代码: LOW ADDRESS (0)
| ---------------------- |
PREFIX
| ---------------------- |
CODE
| ---------------------- |
DATA
| ---------------------- |
HEAP
| |
| |
FREE SPACE
| |
| |
STACK
| ---------------------- |
HIGH ADDRESS
堆和栈共享一个公共的内存区域。它们从该区域的两端开始,并向相反方向增长。堆向高地址增长,栈向低地址增长。由于它们共享同一内存区域,因此可能会相互覆盖(当堆指针和栈指针碰撞时)。发生这种情况时,AMX 会中止并显示如下错误信息:
代码: Script[gamemodes/TEST.amx]: Run time error 3: "Stack/heap collision (insufficient stack size)"
根据编译器掌握的信息,它会估算并间接地在 AMX 二进制文件的前缀部分设置堆栈大小。然而,程序员可以使用 #pragma dynamic [estimated number of cells] 指令提供自己对所需堆栈区域大小的估计。
前缀部分的字段
| 类型 |
大小 |
描述 |
| size |
4 |
内存映像的大小;不包括栈和堆 |
| magic |
2 |
指示格式和单元格大小 |
| file version |
1 |
格式版本 |
| amx version |
1 |
抽象机所需的最低版本 |
| flags |
2 |
是否存在符号调试信息、紧凑编码等。 |
| defsize |
2 |
表中记录的大小 |
| cod |
4 |
代码部分起始的偏移量 |
| dat |
4 |
数据部分起始的偏移量 |
| hea |
4 |
HEA 寄存器的初始值(标记数据段结束) |
| stp |
4 |
STP 寄存器的初始值(指示总内存需求) |
| cip |
4 |
CIP 寄存器的初始值(main 函数的地址;如果不存在则为 -1) |
| publics |
4 |
公共函数表起始的偏移量 |
| natives |
4 |
原生函数表起始的偏移量 |
| libraries |
4 |
库表起始的偏移量 |
| pubvars |
4 |
公共变量表起始的偏移量 |
| tags |
4 |
标签表起始的偏移量 |
| name table |
4 |
名称表起始的偏移量 |
| overlays |
4 |
覆盖表起始的偏移量 |
| publics |
可变 |
公共函数列表 |
| natives |
可变 |
原生函数列表 |
| libraries |
可变 |
库列表 |
| pubvars |
可变 |
公共变量列表 |
| tags |
可变 |
公共标签列表 |
| overlays |
可变 |
覆盖列表 |
| name table |
可变 |
符号名称列表 |
注意:关于 magic、version 和 flags 字段中信息如何编码的详细信息,请参阅 Pawn 实现者指南。
注意:前缀中的所有多字节字段都以小端格式存储,无论 AMX 二进制文件是在哪个平台上生成或将在哪个平台上执行。
可以看出,前缀由一个固定部分组成,后面跟着一系列表。除了名称表之外,表中的每条记录都由两个字段组成,如下所示:
| 字段 |
大小 |
描述 |
| 变量 |
单元格大小 |
变量 |
| 名称字符串偏移量 |
4 字节 |
从前缀起始到名称表中字符串的偏移量 |
表中每条记录(除了名称表)的大小由前缀中的 defsize 字段给出:defsize = 4 + 单元格大小。
表中的每条记录都被分配了一个索引号。每个表的第一个记录被分配索引 0,后续记录的索引号是前一个记录索引加 1。
名称表:
除了名称表本身之外,其他表中的记录包含一个指向字符串的指针。这些字符串作为以空字符结尾的 C 字符串存储在名称表中。此表中记录的大小是其包含的字符串的大小。
公共函数表:
为脚本中定义的每个公共函数创建一条记录。这些记录包含公共函数的地址(函数第一条指令的地址)和指向相应公共函数名称的偏移量。表中记录的索引就是相应公共函数的索引。
原生函数表:
为脚本中调用的每个原生函数创建一条记录。记录的第一个字段由编译器在二进制文件中设置为 0,但宿主程序在加载二进制文件时会将该字段初始化为函数的地址(位于宿主程序的地址空间中)。第二个字段包含指向原生函数名称的偏移量。表中记录的索引就是相应原生函数的索引。
库表:
Pawn 语言提供了一个 pragma 指令 #pragma directive [library name],用于告知编译器调用的某些原生函数需要扩展模块。对于在脚本中调用了其原生函数的库,会在库表中创建一条记录。目的是告知宿主程序 AMX 程序依赖于一个扩展模块。记录的第一个字段供内部使用,在 AMX 二进制文件中设置为 0。另一个字段包含从前缀起始到名称表中库名称的偏移量。
公共变量表:
为脚本中声明的每个公共变量创建一条记录。该记录包含公共变量的地址和指向公共变量名称的偏移量。表中记录的索引就是公共变量的索引。
标签表:
与 sleep 和 exit 语句一起使用的标签以及与 tagof 运算符一起使用的标签会被导出。为每个这样的标签创建一条记录。第一个字段包含标签 id 号,第二个字段存储指向标签名称的偏移量。
指令集
<a name="pawn-inline-assembly"></a> Pawn 编译器接受的大部分 AMX 汇编指令可以用以下格式表示:
代码: mnemonic[.prefix][.register suffix] operand
SHL.C.pri 3
ZERO.alt
ADD.C 100
LIDX
助记符给出了指令功能的提示。可选的前缀指示指令操作数的类型。可选的尾缀指示指令主要作用于哪个寄存器。
前缀列表:- .C = 常量
- .S = 栈
- .I = 间接寻址
- .B = 无 B 变体的变体<sup>\[需要更好的描述\]</sup>
- .ADR = 地址
- .R = 重复
寄存器尾缀列表:
每条指令的二进制形式需要一个单元格来存储操作码,每个操作数还需要一个额外的单元格。绝大多数指令使用隐含寄存器作为操作数。这减少了所需显式操作数的数量,从而减小了代码段的大小并提高了性能。
<a name="instruction-table"></a> 阅读表格:
- [address] 指的是存储在位置
DAT + address 的值
- 语义列中使用的运算符执行与 Pawn 中相同的操作
| 1 |
LOAD.pri |
address |
PRI = [address] |
| 2 |
LOAD.alt |
address |
ALT = [address] |
| 3 |
LOAD.S.pri |
offset |
PRI = [FRM + offset] |
| 4 |
LOAD.S.alt |
offset |
ALT = [FRM + offset] |
| 5 |
LREF.pri |
address |
PRI = [[address]] |
| 6 |
LREF.alt |
address |
ALT = [[address]] |
| 7 |
LREF.S.pri |
offset |
PRI = [[FRM + offset]] |
| 8 |
LREF.S.alt |
offset |
ALT = [[FRM + offset]] |
| 9 |
LOAD.I |
|
PRI = [PRI] |
| 10 |
LODB.I |
number |
PRI = 'number' of bytes from [PRI] (read 1/2/4 bytes) |
| 11 |
CONST.pri |
value |
PRI = value |
| 12 |
CONST.alt |
value |
ALT = value |
| 13 |
ADDR.pri |
offset |
PRI = FRM + offset |
| 14 |
ADDR.alt |
offset |
ALT = FRM + offset |
| 15 |
STOR.pri |
address |
[address] = PRI |
| 16 |
STOR.alt |
address |
[address] = ALT |
| 17 |
STOR.S.pri |
offset |
[FRM + offset] = PRI |
| 18 |
STOR.S.alt |
offset |
[FRM + offset] = ALT |
| 19 |
SREF.pri |
address |
[[address]] = PRI |
| 20 |
SREF.alt |
address |
[[address]] = ALT |
| 21 |
SREF.S.pri |
offset |
[[FRM + offset]] = PRI |
| 22 |
SREF.S.alt |
offset |
[[FRM + offset]] = ALT |
| 23 |
STOR.I |
|
[ALT] = PRI (full cell) |
| 24 |
STRB.I |
number |
number of bytes at [ALT] = PRI (store 1/2/4 bytes) |
| 25 |
LIDX |
|
PRI = [ALT + (PRI x cell size)] |
| 26 |
LIDX.B |
shift |
PRI = [ALT + (PRI << shift)] |
| 27 |
IDXADDR |
|
PRI = ALT + (PRI x cell size) (calculate indexed address) |
| 28 |
IDXADDR.B |
shift |
PRI = ALT + (PRI << shift) (calculate indexed address) |
| 29 |
ALIGN.pri |
number |
Little Endian: PRI ^= cell size - number |
| 30 |
ALIGN.alt |
number |
Little Endian: ALT ^= cell size - number |
| 31 |
LCTRL |
index |
PRI = value contained in the selected register; 1=COD, 1=DAT, 2=HEA,3=STP, 4=STK, 5=FRM, 6=CIP |
| 32 |
SCTRL |
index |
selected register = PRI; 2=HEA, 4=STK, 5=FRM, 6=CIP |
| 33 |
MOVE.pri |
|
PRI = ALT |
| 34 |
MOVE.alt |
|
ALT = PRI |
| 35 |
XCHG |
|
Exchange contents of PRI and ALT |
| 36 |
PUSH.pri |
|
STK = STK - cell size, [STK] = PRI |
| 37 |
PUSH.alt |
|
STK = STK - cell size, [STK] = ALT |
| 38 |
PUSH.R |
number |
repeat (STK = STK - cell size, [STK] = PRI) 'number' times |
| 39 |
PUSH.C |
value |
STK = STK - cell size, [STK] = value |
| 40 |
PUSH |
address |
STK = STK - cell size, [STK] = [address] |
| 41 |
PUSH.S |
offset |
STK = STK - cell size, [STK] = [FRM + offset] |
| 42 |
POP.pri |
|
PRI = [STK], STK = STK + cell size |
| 43 |
POP.alt |
|
ALT = [STK], STK = STK + cell size |
| 44 |
STACK |
value |
ALT = STK, STK = STK + value |
| 45 |
HEAP |
value |
ALT = HEA, HEA = HEA + value |
| 46 |
PROC |
|
STK = STK - cell size, [STK] = FRM, FRM = STK |
| 47 |
RET |
|
FRM = [STK], STK = STK + cell size, CIP = [STK], STK = STK + cell size |
| 48 |
RETN |
|
FRM = [STK], STK = STK + cell size, CIP = [STK], STK = STK + cell size, STK = STK + [STK] + cell size |
| 49 |
CALL |
offset |
STK = STK − cell size, [STK] = CIP, CIP = offset |
| 50 |
CALL.pri |
|
STK = STK − cell size, [STK] = CIP, CIP = PRI |
| 51 |
JUMP |
offset |
CIP = offset |
| 53 |
JZER |
offset |
if PRI == 0 then CIP = offset |
| 54 |
JNZ |
offset |
if PRI != 0 then CIP = offset |
| 55 |
JEQ |
offset |
if PRI == ALT then CIP = offset |
| 56 |
JNEQ |
offset |
if PRI != ALT then CIP = offset |
| 57 |
JLESS |
offset |
if PRI < ALT (unsigned) then CIP = offset |
| 58 |
JLEQ |
offset |
if PRI <= ALT (unsigned) then CIP = offset |
| 59 |
JGRTR |
offset |
if PRI > ALT (unsigned) then CIP = offset |
| 60 |
JGEQ |
offset |
if PRI >= ALT (unsigned) then CIP = offset |
| 61 |
JSLESS |
offset |
if PRI < ALT (signed) then CIP = offset |
| 62 |
JSLEQ |
offset |
if PRI <= ALT (signed) then CIP = offset |
| 63 |
JSGRTR |
offset |
if PRI > ALT (signed) then CIP = offset |
| 64 |
JSGEQ |
offset |
if PRI >= ALT (signed) then CIP = offset |
| 65 |
SHL |
|
PRI = PRI << ALT |
| 66 |
SHR |
|
PRI = PRI >> ALT (without sign extension) |
| 67 |
SSHR |
|
PRI = PRI >> ALT (with sign extension) |
| 68 |
SHL.C.pri |
value |
PRI = PRI << value |
| 69 |
SHL.C.alt |
value |
ALT = ALT << value |
| 70 |
SHR.C.pri |
value |
PRI = PRI >> value |
| 71 |
SHR.C.alt |
value |
ALT = ALT >> value |
| 72 |
SMUL |
|
PRI = PRI * ALT (signed multiply) |
| 73 |
SDIV |
|
PRI = PRI / ALT (signed divide), ALT = PRI mod ALT |
| 74 |
SDIV.alt |
|
PRI = ALT / PRI (signed divide), ALT = ALT mod PRI |
| 75 |
UMUL |
|
PRI = PRI * ALT (unsigned multiply) |
| 76 |
UDIV |
|
PRI = PRI / ALT (unsigned divide), ALT = PRI mod ALT |
| 77 |
UDIV.alt |
|
PRI = ALT / PRI (unsigned divide), ALT = ALT mod PRI |
| 78 |
ADD |
|
PRI = PRI + ALT |
| 79 |
SUB |
|
PRI = PRI - ALT |
| 80 |
SUB.alt |
|
PRI = ALT - PRI |
| 81 |
AND |
|
PRI = PRI & ALT |
| 82 |
OR |
|
PRI = PRI | ALT |
| 83 |
XOR |
|
PRI = PRI ^ ALT |
| 84 |
NOT |
|
PRI = !PRI |
| 85 |
NEG |
|
PRI = -PRI |
| 86 |
INVERT |
|
PRI = ~PRI |
| 87 |
ADD.C |
value |
PRI = PRI + value |
| 88 |
SMUL.C |
value |
PRI = PRI * value |
| 89 |
ZERO.pri |
|
PRI = 0 |
| 90 |
ZERO.alt |
|
ALT = 0 |
| 91 |
ZERO |
address |
[address] = 0 |
| 92 |
ZERO.S |
offset |
[FRM + offset] = 0 |
| 93 |
SIGN.pri |
|
sign extend the byte in PRI to a cell |
| 94 |
SIGN.alt |
|
sign extend the byte in ALT to a cell |
| 95 |
EQ |
|
PRI = PRI == ALT ? 1 : 0 |
| 96 |
NEQ |
|
PRI = PRI != ALT ? 1 : 0 |
| 97 |
LESS |
|
PRI = PRI < ALT ? 1 : 0 (unsigned) |
| 98 |
LEQ |
|
PRI = PRI <= ALT ? 1 : 0 (unsigned) |
| 99 |
GRTR |
|
PRI = PRI > ALT ? 1 : 0 (unsigned) |
| 100 |
GEQ |
|
PRI = PRI >= ALT ? 1 : 0 (unsigned) |
| 101 |
SLESS |
|
PRI = PRI < ALT ? 1 : 0 (signed) |
| 102 |
SLEQ |
|
PRI = PRI <= ALT ? 1 : 0 (signed) |
| 103 |
SGRTR |
|
PRI = PRI > ALT ? 1 : 0 (signed) |
| 104 |
SGEQ |
|
PRI = PRI >= ALT ? 1 : 0 (signed) |
| 105 |
EQ.C.pri |
value |
PRI = PRI == value ? 1 : 0 |
| 106 |
EQ.C.alt |
value |
PRI = ALT == value ? 1 : 0 |
| 107 |
INC.pri |
|
PRI = PRI + 1 |
| 108 |
INC.alt |
|
ALT = ALT + 1 |
| 109 |
INC |
address |
[address] = [address] + 1 |
| 110 |
INC.S |
offset |
[FRM + offset] = [FRM + offset] + 1 |
| 111 |
INC.I |
|
[PRI] = [PRI] + 1 |
| 112 |
DEC.pri |
|
PRI = PRI - 1 |
| 113 |
DEC.alt |
|
ALT = ALT - 1 |
| 114 |
DEC |
address |
[address] = [address] - 1 |
| 115 |
DEC.S |
offset |
[FRM + offset] = [FRM + offset] - 1 |
| 116 |
DEC.I |
|
[PRI] = [PRI] - 1 |
| 117 |
MOVS |
number |
copy 'number' bytes of non-overlapping memory from [PRI] to [ALT] |
| 118 |
CMPS |
number |
compare 'number' bytes of non-overlapping memory at [PRI] with [ALT] |
| 119 |
FILL |
number |
fill 'number' bytes of memory from [ALT] with value in PRI (number must be multiple of cell size) |
| 120 |
HALT |
0 |
abort execution (exit value in PRI) |
| 121 |
BOUNDS |
value |
abort execution if PRI > value or if PRI < 0 |
| 122 |
SYSREQ.pri |
|
call system service, service number in PRI |
| 123 |
SYSREQ.C |
value |
call system service |
| 128 |
JUMP.pri |
|
CIP = PRI |
| 129 |
SWITCH |
offset |
compare PRI to the values in the case table (whose address is passed in the 'offset' argument) and jump to the associated address in the matching record |
| 130 |
CASETBL |
... |
a variable number of case records follows this opcode, where each record takes two cells |
| 131 |
SWAP.pri |
|
[STK] = PRI, PRI = [STK] |
| 132 |
SWAP.alt |
|
[STK] = ALT, ALT = [STK] |
| 133 |
PUSH.ADR |
offset |
STK = STK - cell size, [STK] = FRM + offset |
| 134 |
NOP |
|
no operation |
加载/存储指令
当使用全局变量作为操作数时,编译器会替换该变量的地址。下面代码中的第一条加载指令,如果 some_global 的地址是 1288,则实际上变成 #emit LOAD.pri 1288。请注意,编译器替换的地址是从数据段起始的偏移量;因此,该地址是相对于 DAT 寄存器的。
代码: new some_global = 10, another_global = 25;
main()
{
#emit LOAD.pri some_global // 将 'some_global' 的值加载到主寄存器
#emit LOAD.alt another_global // 将 'another_global' 的值加载到辅助寄存器
}
当使用局部变量时,编译器会替换从函数帧起始的偏移量。(稍后解释)
代码: main()
{
static s_local = 20; // 静态局部变量存储在数据段中
new some_local = 10, another_local = 25; // 局部变量存储在栈中
#emit LOAD.S.pri some_local // 将 'some_local' 的值加载到主寄存器
#emit LOAD.S.alt another_local // 将 'another_local' 的值加载到辅助寄存器
#emit LOAD.pri s_local // 注意静态局部变量的行为类似于全局变量
}
由于编译器会为全局变量替换地址,因此可以使用 CONST.pri/CONST.alt 来获取这些变量的地址。
代码: new some_global;
main()
{
#emit CONST.pri 10 // 将 10 放入主寄存器
#emit CONST.alt 50 // 将 50 放入辅助寄存器
#emit CONST.pri some_global // 将 'some_global' 的地址存储在主寄存器中
#emit CONST.alt some_global // 将 'some_global' 的地址存储在辅助寄存器中
}
代码: new some_global;
main()
{
new some_local;
#emit ZERO.pri // 将零存储在主寄存器中
#emit STOR.pri some_global // 将主寄存器中存储的值(本例中为零)设置给 'some_global'
#emit CONST.alt 125
#emit STOR.S.alt some_local // 将辅助寄存器中存储的值(125)设置给 'some_local'
}
索引指令
代码: new global_arr[10];
main ()
{
#emit CONST.alt global_arr // 将 'global_arr' 的地址('global_arr' 第一个元素的地址)加载到辅助寄存器中
#emit CONST.pri 2 // 设置我们感兴趣的 'global_arr' 元素的索引
#emit LIDX // 现在主寄存器中存储的是 'global_arr[2]` 处存储的值
#emit CONST.alt global_arr // 将 'global_arr' 的地址(第一个元素的地址)加载到辅助寄存器中
#emit CONST.pri 2 // 设置我们感兴趣的 'global_arr' 元素的索引
#emit IDXADDR // 现在主寄存器中存储的是 'global_arr[2]` 的地址
}
算术指令
代码: main ()
{
#emit CONST.pri 4
#emit CONST.alt 5
#emit SMUL // 现在主寄存器中是 20(SMUL 执行有符号乘法;UMUL 执行无符号乘法)
#emit ADD // 将 5 加到 20(由于上一条指令,主寄存器中存储的是 20)
#emit ADD.C 10 // 将 10 加到主寄存器;现在主寄存器中是 35
#emit SUB.alt // 用 5 减去 35;现在主寄存器中是 -30
#emit SMUL.C 2 // -30 乘以 2;得到 -60
}
逻辑指令
代码: main ()
{
#emit CONST.pri 5 // .. 0000 0101
#emit CONST.alt 3 // ... 0000 0011
#emit AND // 主寄存器现在包含 ... 0000 0001
#emit XOR // 主寄存器现在包含 ... 0000 0110
#emit INVERT // 对主寄存器中存储的值取反码
#emit NEG // 对主寄存器中存储的值取补码(本质上是取负)
}
关系指令
代码: main ()
{
#emit CONST.pri 5
#emit CONST.alt 8
#emit EQ // 如果 5 == 8,则将主寄存器设置为 1,否则为 0
#emit LESS // 如果 0 < 8,则将主寄存器设置为 1,否则为 0
}
栈操作指令
局部变量存储在栈上。详细说明将在后续章节中提供,但现在我们假设使用 CONST.pri some_local 会得到某个偏移量,该偏移量加上帧寄存器中存储的基地址即得数据地址。
代码: main ()
{
new some_local = 25;
#emit ADDR.alt some_local // 计算 'some_local' 的地址
#emit CONST.pri 100
#emit STOR.I // 将 100 存储到 'some_local'
#emit CONST.pri 100
#emit STOR.S.pri some_local // 实现上述代码功能的更好方法
#emit CONST.pri some_local // 神秘地等价于 CONST.pri -4(稍后解释)
}
代码: main ()
{
#emit PUSH.C 100 // 将值 100 压入调用栈
#emit POP.pri // 从栈中弹出一个值并将结果存储在主寄存器中
#emit PUSH.pri // 推送主寄存器的值
#emit PUSH.alt // 推送辅助寄存器的值
#emit POP.pri
#emit POP.alt // 最后 4 条指令有效地交换了主寄存器和辅助寄存器的内容
#emit XCHG // 交换主寄存器和辅助寄存器内容的更好方法
}
局部数组位于栈上。因此,在使用索引指令之前,必须使用 ADDR.alt/ADDR.pri 来获取完整的地址。
代码: main ()
{
new local_array[10];
#emit ADDR.alt local_array // 加载存储在 'local_array' 中的值,即数组的地址
#emit CONST.pri 5
#emit LIDX // 有效地将 'local_array[5]' 的值存储在主寄存器中
}
堆指令
堆指针(HEA 寄存器)指向堆的顶部。将堆指针向前移动 x 字节,我们就能有效地
在堆上预留 x 字节。
代码: main ()
{
#emit HEAP 16 // 为四个单元格预留空间(假设单元格为 4 字节)
// 注意 HEAP 指令也将辅助寄存器设置为我们预留内存的起始地址
// ALT = HEA, HEA += 16
#emit CONST.pri 50
#emit STOR.I // 有效地将值 50 存储在我们预留的堆区域的第一个单元格中
#emit HEAP -16 // 归还预留的内存
}
控制寄存器操作指令
可以使用 LCTRL 和 SCTRL 指令直接读取和修改专用寄存器的内容。
| 助记符 |
操作数 |
描述 |
| LCTRL |
index |
PRI = 所选寄存器中包含的值;0=COD, 1=DAT, 2=HEA, 3=STP, 4=STK, 5=FRM, 6=CIP |
| SCTRL |
index |
所选寄存器 = PRI;2=HEA, 4=STK, 5=FRM, 6=CIP |
代码: main ()
{
new cod, dat;
#emit LCTRL 0 // 将 COD 段寄存器的值存储在主寄存器中
#emit STOR.S.pri cod
#emit LCTRL 1 // 将 DAT 段寄存器的值存储在主寄存器中
#emit STOR.S.pri dat
printf("%d %d", cod, dat);
}
当函数名用作操作数时,编译器会用函数的地址替换它。替换的地址是相对于 COD 寄存器的。
代码: f()
{
print("f() was called.");
}
main ()
{
#emit PUSH.C 0 // 参数占用的字节数(稍后解释)
#emit LCTRL 6 // 获取 CIP 的值,即下一条指令(本例中的 ADD.C)的地址
#emit ADD.C 28 // 计算 'f' 之后要执行的指令的地址(注意每个操作码和操作数都需要一个单元格)
#emit PUSH.pri // 推送返回地址,以便 'f' 知道返回到哪里(稍后解释)
#emit CONST.pri f // 将函数 'f' 的地址存储在 pri 中
#emit SCTRL 6 // 将当前指令指针设置为存储在主寄存器中的值
// 函数 'f' 执行
// 函数 'f' 返回后,下一条指令(本例中的 NOP)将开始执行
#emit NOP // 不执行任何操作的指令
}
控制流指令
代码: main()
{
#emit JUMP check // 跳转到 check 标签
not_equal:
printf("1 is not equal to 2");
return 0;
equal:
print("1 is equal to 2");
return 0;
check:
#emit CONST.pri 1
#emit CONST.alt 2
#emit JEQ equal // 如果主寄存器的值等于辅助寄存器的值,则跳转到 'equal'
#emit JUMP not_equal // 如果执行到这里,意味着两个寄存器的值不相等;因此跳转到 'not_equal'
}
Switch case 指令
switch-case 块使用分支表实现。分支表仅仅是由 case 值及其对应的跳转地址组成的元组列表。对于给定的 case 值,AMX 会搜索分支表并跳转到相应的跳转地址。分支表中的记录按 case 值的升序排列。这允许 AMX 对分支表执行二分搜索,但它也可能执行线性搜索。
SWITCH 指令标记 switch-case 块的开始。它接受一个指向分支表(也位于代码段中)的偏移量作为操作数。分支表正式以 CASETBL 操作码(仅作为标记,功能上未使用)开始,后跟一系列 CASE 记录,每条记录占用两个参数。第一条 case 记录的参数具有特殊含义:第一个参数是分支表中的 case 数量,第二个参数是默认 case 的偏移量。如果没有提供默认 case,则第二个参数包含 switch-case 块之后指令的偏移量。其余的 CASE 记录包含 case 值及其对应的跳转地址。
代码: switch(expression)
{
case 2: {}
case 4: {}
case 3: {}
case 7: {}
case 5: {}
}
编译器会添加指令来计算给定的 expression,其结果存储在主寄存器中。紧接着是一条 SWITCH 指令,它使 AMX 搜索分支表,查找与主寄存器中存储的值匹配的 case 值。如果找到匹配项,则执行跳转到匹配记录指向的地址;否则,跳转到默认地址。
代码: ; 编译器通过在汇编输出中使用标签而不是实际的偏移量/地址,使阅读更容易
; 每个标签都以前缀 "l." 开头
switch 0 ; 注意这里的零是一个标签
l.2 ; 标签 2
jump 1 ; 跳转到标签 1
l.3
jump 1
l.4
jump 1
l.5
jump 1
l.6
jump 1
l.0
casetbl
case 5 1 ; 记录数量,默认跳转地址(本例中为标签 1)
case 2 2 ; 真正的第一条记录
case 3 4 ; case 值: 3, 跳转标签: 4
case 4 3 ; 编译器在实际二进制文件中会用正确的地址替换标签
case 5 6 ;
case 7 5 ; 最后一条记录
l.1
; 其余代码
|
|
|
|
| [wiki系列] openmp/samp 关键字:指令 |
|
发布者: 小鸟unsigned - 03-21-2026, 11:06 PM - 板块: 教程
- 暂无回复
|
 |
[wiki系列] openmp/samp 关键字:指令
来自 SA-MP Wiki
目录
#assert
此指令会检查常量表达式是否为真,如果不为真则停止编译。
代码: #define MOO 10
#assert MOO > 5
以上代码可以正常编译。
代码: #define MOO 1
#assert MOO > 5
以上代码无法编译,会产生致命错误。
这类似于:
代码: #define MOO 1
#if MOO <= 5
#error Moo check failed
#endif
但是 #assert 会给出以下错误信息:
代码: "Assertation failed: 1 > 5"
而第二种写法会给出:
代码: "User error: Moo check failed"
哪种错误信息更实用,则视情况而定。
#define
#define 是一个文本替换指令。只要在代码中找到被定义的符号(宏名),就会将其替换为定义的内容。
代码: #define MOO 7
printf("%d", MOO);
会被替换为:
这也是为什么反编译后的代码中看不到任何 #define,因为所有指令都在预处理器阶段处理完毕。定义的内容不一定是数字:
代码: #define PL new i = 0; i < MAX_PLAYERS; i++) if (IsPlayerConnected(i)
for (PL)
{
printf("%d connected", i);
}
会编译成大家熟悉(或讨厌)的玩家循环。注意括号的使用:一部分来自 for 语句,一部分来自宏本身。
另一个鲜为人知的特性是:定义可以跨行(通过在行尾加上反斜杠 \ 来转义换行)。一般来说换行会结束定义,但下面这样是有效的:
代码: #define PL \
new i = 0; i < MAX_PLAYERS; i++) \
if (IsPlayerConnected(i)
for (PL)
{
printf("%d connected", i);
}
\ 字符表示定义继续到下一行。
#define 的第三个重要特性是支持参数(宏参数)。参数用 %0 到 %9 表示,用法与普通函数参数类似:
代码: #define MOO(%0) \
((%0) * 7)
printf("%d", MOO(6));
输出结果为 42(不是随意选的)。注意宏里多余的括号——这是因为宏是纯文本替换,所以实际编译成:
代码: printf("%d", ((6) * 7));
这样是安全的。但看下面这个例子:
代码: printf("%d", MOO(5 + 6));
你会期望结果是 77((5 + 6) * 7)。加上括号后确实如此;如果去掉括号:
代码: #define MOO(%0) \
%0 * 7
printf("%d", MOO(5 + 6));
实际会变成:
代码: printf("%d", 5 + 6 * 7);
由于运算优先级(BODMAS),结果变为 47(5 + (6 * 7)),这就完全错误了。
关于参数还有一个有趣的事实:如果传入的参数数量超过定义的数量,最后一个参数会包含所有多余的部分。例如:
代码: #define PP(%0,%1) \
printf(%0, %1)
PP("%s %s %s", "hi", "hello", "hi");
实际会打印:
因为 %1 包含了 "hi", "hello", "hi"。你可能还注意到 # 可以把字面量转换成字符串,这是 openmp(samp) 特有的功能,这里只是为了区分参数。
#else
#else 相当于普通 else,但用于 #if 条件。
#elseif
#elseif 相当于 else if,但用于 #if 条件。
代码: #define MOO 10
#if MOO == 9
printf("if");
#elseif MOO == 8
printf("else if");
#else
printf("else");
#endif
#emit
此指令在 pawn-lang.pdf 的表格中并未列出,但实际存在。它相当于内联汇编器,如果你熟悉 AMX 字节码,可以用它直接插入 AMX 操作码。唯一限制是每次只能带一个参数。
语法:#emit <opcode> <argument>
<argument> 可以是小数、整数或(局部/全局)符号(变量、函数、标签)。
操作码列表及其含义可在 Pawn Toolkit ver. 3664 中找到。
#endif
#endif 相当于 #if 的结束括号。#if 不使用大括号,所有条件内容一直持续到对应的 #endif。
#endinput , #endscript
此指令停止当前文件的包含(不再继续读取该文件)。
#error
此指令立即停止编译,并输出自定义错误信息。示例见 #assert。
#if
#if 是预处理器版本的 if。你可以精确控制哪些代码被编译、哪些不被编译。例如:
代码: #define LIMIT 10
if (LIMIT < 10)
{
printf("Limit too low");
}
会编译成:
代码: if (10 < 10)
{
printf("Limit too low");
}
编译器知道这个条件永远为假,因此会给出“常量表达式”警告。但既然永远不会成立,为什么还要保留这段代码呢?你可以直接删掉,但之后别人修改 LIMIT 重新编译时就无法检查了。这就是 #if 的作用。
普通 if 在常量表达式时会警告,而 #if 必须 是常量表达式:
代码: #define LIMIT 10
#if LIMIT < 10
#error Limit too low
#endif
这样会在编译时就检查限制是否过小,如果是则直接报错,而不用运行脚本测试。同时也不会生成多余的代码。注意 #if 不强制使用括号(虽然复杂表达式可能需要)。
另一个例子:
代码: #define LIMIT 10
if (LIMIT < 10)
{
printf("Limit less than 10");
}
else
{
printf("Limit equal to or above 10");
}
同样是常量检查,会产生警告,而且两个 printf 都会被编译(尽管我们知道只会执行一个)。用 #if 改写后:
代码: #define LIMIT 10
#if LIMIT < 10
printf("Limit less than 10");
#else
printf("Limit equal to or above 10");
#endif
这样只有需要的 printf 会被编译,另一个仍保留在源代码中(方便以后改 LIMIT),但不会占用最终编译后的代码空间,也不会每次运行都执行无用的判断。
#include
此指令会把指定文件的所有代码插入到 #include 所在的位置。有两种包含方式:相对路径(用双引号)和系统路径(用尖括号,我自己起的名称,如果你有更好的叫法请告诉我)。
相对包含使用双引号,路径相对于当前文件:
会包含与当前文件同目录下的 me.pwn。
系统包含使用尖括号,从 Pawn 编译器所在目录下的 include 文件夹(或其父目录的 include 文件夹)中查找:
会包含 qawno/include/me.inc(注意没有扩展名;如果文件不是 .p 或 .inc,可以指定扩展名)。
两种方式都支持子目录:
代码: #include "folder/me.pwn"
#include <folder/me>
如果文件不存在,编译会立即失败。
#pragma
说明
这是最复杂的指令之一。它提供了一系列选项来控制脚本的编译行为。例如:
会把转义字符从 \ 改成 $,所以换行符不再是 "\r\n",而是 "$r$n"。
很多选项原本是为嵌入式系统设计的,用来限制 PC 上几乎无限制的资源。这里只列出与 openmp(samp) 相关的选项(完整列表见 pawn-lang.pdf)。
列表
| 名称 |
值 |
说明 |
| codepage |
名称/值 |
设置字符串使用的 Unicode 码页 |
| compress |
1/0 |
openmp(samp) 不支持,请勿使用 |
| deprecated |
符号名称 |
如果使用该符号会产生警告,用于提示有更好的替代版本 |
| dynamic |
值(通常是 2 的幂) |
设置栈和堆内存大小(单位:cells)。出现“excessive memory usage”警告时需要设置 |
| library |
dll 名称 |
在 openmp(samp) 中经常被误用。它指定本文件中的原生函数来自哪个 DLL,并非把文件定义为库 |
| pack |
1/0 |
交换 !"" 和 "" 的含义(详见 pawn-lang.pdf 中的打包字符串说明) |
| tabsize |
值 |
经常被误用。用于设置制表符宽度,避免因空格和制表符混用产生的警告。openmp(samp) 默认设为 4(qawno 编辑器中制表符宽度)。设为 0 会关闭所有缩进警告,但强烈不推荐(会导致代码难以阅读) |
| unused |
符号名称 |
类似 deprecated,用于抑制“symbol is never used”警告。推荐使用 stock,但某些情况(如函数参数)无法使用 stock 时才用此方式 |
示例
已弃用(Deprecated)
代码: new
gOldVariable = 5;
#pragma deprecated gOldVariable
main()
{
printf("%d", gOldVariable);
}
使用 gOldVariable 时会产生警告,提示不应再使用它。主要用于函数更新 API 时保留向下兼容性。
#tryinclude
类似于 #include,但如果文件不存在,编译不会失败。这非常适合根据用户是否安装了特定插件(或插件的 include 文件)来选择性包含功能。
myinc.inc:
代码: #if defined _MY_INC_INC
#endinput
#endif
#define _MY_INC_INC
stock MyIncFunc()
{
printf("Hello");
}
主脚本:
代码: #tryinclude <myinc>
main()
{
#if defined _MY_INC_INC
MyIncFunc();
#endif
}
只有当 myinc.inc 存在并被成功包含时,才会调用 MyIncFunc()。这对 IRC 插件等需要检测插件是否安装的情况非常有用。
#undef
移除之前定义的宏或常量符号。
代码: #define MOO 10
printf("%d", MOO);
#undef MOO
printf("%d", MOO);
第二个 printf 会编译失败,因为 MOO 已被取消定义。
代码: enum
{
e_example = 300
};
printf("%d", e_example);
#undef e_example
printf("%d", e_example); // 致命错误
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
| [wiki系列] openmp/samp 关键字:运算符 |
|
发布者: 小鸟unsigned - 03-21-2026, 11:06 PM - 板块: 教程
- 暂无回复
|
 |
[wiki系列] openmp/samp 关键字:运算符
来自 SA-MP Wiki
目录
char
char 返回存放给定数量字符(打包字符串)所需的 cells 数量。
即:存放给定字节数所需的 4 字节 cells 数量。
示例:
返回 1。
返回 1(你不能拥有 3/4 个变量)。
返回 64(256 ÷ 4)。
此运算符通常用于变量声明:
会创建一个10 cells大小的数组。
更多关于打包字符串(packed strings)的细节,请阅读 pawn-lang.pdf。
defined
检查一个符号(symbol)是否存在。通常用于 #if 语句:
代码: new
someVar = 5;
#if defined someVar
printf("%d", someVar);
#else
#error The variable 'someVar' isn't defined
#endif
最常见的用法是检查某个 #define 是否存在,并据此生成不同代码:
代码: #define FILTERSCRIPT
#if defined FILTERSCRIPT
public OnFilterScriptInit()
{
return 1;
}
#else
public OnGameModeInit()
{
return 1;
}
#endif
sizeof
返回数组元素(ELEMENTS)的数量:
代码: new
someVar[10];
printf("%d", sizeof (someVar));
输出:
二维数组示例:
代码: new
someVar[2][10];
printf("%d %d", sizeof (someVar), sizeof (someVar[]));
输出:
state
此关键字与 PAWN 自动机(autonoma)代码相关,因此本文不做介绍。
tagof
tagof 返回一个代表变量标签(tag)的数字:
代码: new
someVar,
Float:someFloat;
printf("%d %d", tagof (someVar), tagof (someFloat));
输出:
代码: -./,)),(-*,( -1073741820
这其实是一个轻微的显示 bug,但本质上代表:
实际应用:检查变量是否为 Float
代码: new Float: fValue = 6.9;
new tag = tagof (fValue);
if (tag == tagof (Float:))
print("float");
else
print("not a float");
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
|